mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c63198237 | ||
|
|
c0fbe54ef6 | ||
|
|
4ba39ef392 | ||
|
|
25c72ced6f | ||
|
|
0ed63b02e4 | ||
|
|
5352e6a90a | ||
|
|
16f1a0f320 | ||
|
|
4d625420b0 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.28] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
|
||||
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Handle duplicate remote sync paths (#803)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
|
||||
|
||||
## [v1.0.27] - 2026-05-09
|
||||
|
||||
### Features
|
||||
@@ -644,6 +659,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.28",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,6 +67,7 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if v := runtime.Str("parent-position"); v != "" {
|
||||
body["parent_position"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if ro := buildReadOption(runtime); ro != nil {
|
||||
body["read_option"] = ro
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
95
shortcuts/doc/docs_fetch_v2_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
|
||||
runtime := newFetchBodyTestRuntime(ctx)
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newCreateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildCreateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
|
||||
runtime := newUpdateBodyTestRuntime(ctx)
|
||||
|
||||
body := buildUpdateBody(runtime)
|
||||
if got := body["scene"]; got != "DoubaoCLI" {
|
||||
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["scene"]; ok {
|
||||
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("content", "<title>hello</title>", "")
|
||||
cmd.Flags().String("parent-token", "", "")
|
||||
cmd.Flags().String("parent-position", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+update"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
@@ -162,5 +162,6 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
body["src_block_ids"] = v
|
||||
}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
|
||||
// scene without exposing it as a user-controlled CLI flag.
|
||||
const docsSceneContextKey = "lark_cli_docs_scene"
|
||||
|
||||
type documentRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -65,6 +70,20 @@ func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body inter
|
||||
return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body)
|
||||
}
|
||||
|
||||
func docsSceneFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
scene, _ := ctx.Value(docsSceneContextKey).(string)
|
||||
return strings.TrimSpace(scene)
|
||||
}
|
||||
|
||||
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
|
||||
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
|
||||
body["scene"] = scene
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveRouteExtra(docID string) (string, error) {
|
||||
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
|
||||
if err != nil {
|
||||
|
||||
865
shortcuts/drive/drive_duplicate_remote_test.go
Normal file
865
shortcuts/drive/drive_duplicate_remote_test.go
Normal file
@@ -0,0 +1,865 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
duplicateRemoteFileIDFirst = "example-file-token-first"
|
||||
duplicateRemoteFileIDSecond = "example-file-token-second"
|
||||
duplicateRemoteFileIDThird = "example-file-token-third"
|
||||
duplicateRemoteFolderID = "example-folder-token"
|
||||
)
|
||||
|
||||
func TestDriveStatusFailsOnDuplicateRemoteFiles(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullFailsOnDuplicateRemoteFilesBeforeWriting(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
|
||||
if _, statErr := os.Stat(filepath.Join("local", "dup.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("duplicate default failure must not write local dup.txt; stat err=%v", statErr)
|
||||
}
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameDownloadsDuplicateRemoteFilesWithStableHashSuffix(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst + "/download",
|
||||
Status: 200,
|
||||
Body: []byte("FIRST"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond + "/download",
|
||||
Status: 200,
|
||||
Body: []byte("SECOND"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
|
||||
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
|
||||
if strings.Contains(renamedRelPath, duplicateRemoteFileIDSecond) {
|
||||
t.Fatalf("renamed rel_path should not expose raw file token: %s", renamedRelPath)
|
||||
}
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 2 {
|
||||
t.Fatalf("summary.downloaded = %d, want 2", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
|
||||
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
|
||||
}
|
||||
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameStrengthensSuffixWhenShortHashTargetAlreadyExists(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
shortHashRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
{"token": duplicateRemoteFileIDThird, "name": shortHashRelPath, "type": "file", "size": 7, "created_time": "3", "modified_time": "3"},
|
||||
})
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
registerDownload(reg, duplicateRemoteFileIDThird, "THIRD")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
occupied := occupiedRemotePaths([]driveRemoteEntry{
|
||||
{RelPath: "dup.txt"},
|
||||
{RelPath: "dup.txt"},
|
||||
{RelPath: shortHashRelPath},
|
||||
})
|
||||
strongerRelPath, err := relPathWithUniqueFileTokenSuffix("dup.txt", duplicateRemoteFileIDSecond, occupied)
|
||||
if err != nil {
|
||||
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
|
||||
}
|
||||
if strongerRelPath == shortHashRelPath {
|
||||
t.Fatalf("expected stronger unique suffix when %q is already occupied", shortHashRelPath)
|
||||
}
|
||||
mustReadFile(t, filepath.Join("local", shortHashRelPath), "THIRD")
|
||||
mustReadFile(t, filepath.Join("local", strongerRelPath), "SECOND")
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 3 {
|
||||
t.Fatalf("summary.downloaded = %d, want 3", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, strongerRelPath); item.SourceID == "" || item.FileToken != "" {
|
||||
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
|
||||
}
|
||||
assertPullItemAction(t, stdout.Bytes(), strongerRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameAppendsSequenceWhenAllHashSuffixTargetsAreOccupied(t *testing.T) {
|
||||
fileToken := duplicateRemoteFileIDSecond
|
||||
tokenHash := stableTokenHash(fileToken)
|
||||
occupied := map[string]struct{}{
|
||||
"dup.txt": {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:12]): {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:24]): {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash): {},
|
||||
relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_2"): {},
|
||||
}
|
||||
|
||||
got, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
|
||||
if err != nil {
|
||||
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
|
||||
}
|
||||
want := relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_3")
|
||||
if got != want {
|
||||
t.Fatalf("unique rel_path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelPathWithUniqueFileTokenSuffixReturnsErrorAfterMaxAttempts(t *testing.T) {
|
||||
fileToken := duplicateRemoteFileIDSecond
|
||||
tokenHash := stableTokenHash(fileToken)
|
||||
occupied := map[string]struct{}{
|
||||
"dup.txt": {},
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"__lark_" + tokenHash[:12],
|
||||
"__lark_" + tokenHash[:24],
|
||||
"__lark_" + tokenHash,
|
||||
} {
|
||||
occupied[relPathWithSuffix("dup.txt", suffix)] = struct{}{}
|
||||
}
|
||||
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
|
||||
occupied[relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))] = struct{}{}
|
||||
}
|
||||
|
||||
_, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
|
||||
if err == nil {
|
||||
t.Fatal("expected relPathWithUniqueFileTokenSuffix to fail after exhausting all suffix attempts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullNewestChoosesMostRecentDuplicateRemoteFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "SECOND")
|
||||
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 1 {
|
||||
t.Fatalf("summary.downloaded = %d, want 1", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("stdout should surface the chosen newest file token, got: %#v", item)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullOldestChoosesOldestDuplicateRemoteFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "oldest",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
|
||||
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 1 {
|
||||
t.Fatalf("summary.downloaded = %d, want 1", got)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDFirst {
|
||||
t.Fatalf("stdout should surface the chosen oldest file token, got: %#v", item)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameHandlesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
renamedRelPath := expectedRenamedRelPath("sub/dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
mustReadFile(t, filepath.Join("local", "sub", "dup.txt"), "FIRST")
|
||||
mustReadFile(t, filepath.Join("local", filepath.FromSlash(renamedRelPath)), "SECOND")
|
||||
assertPullItemAction(t, stdout.Bytes(), "sub/dup.txt", "downloaded")
|
||||
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushFailsOnDuplicateRemoteFilesBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullFailsOnRemoteFileFolderConflictEvenWithRename(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushFailsOnRemoteFileFolderConflictEvenWithNewest(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--if-exists", "skip",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
|
||||
if stdout.String() != "" {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteDeletesUnchosenDuplicateSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "skip",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushOldestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "dup-oldest-new-token",
|
||||
"version": "v11",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "oldest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDFirst {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDFirst)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for the newer duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushNewestResolvesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "nested-dup-new-token",
|
||||
"version": "v7",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "sub/dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for nested duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_b", CreatedTime: "9", ModifiedTime: "9"},
|
||||
{FileToken: "token_a", CreatedTime: "10", ModifiedTime: "10"},
|
||||
}
|
||||
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile newest: %v", err)
|
||||
}
|
||||
if gotNewest.FileToken != "token_a" {
|
||||
t.Fatalf("newest token = %q, want token_a", gotNewest.FileToken)
|
||||
}
|
||||
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile oldest: %v", err)
|
||||
}
|
||||
if gotOldest.FileToken != "token_b" {
|
||||
t.Fatalf("oldest token = %q, want token_b", gotOldest.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
|
||||
{FileToken: "token_b", CreatedTime: "10", ModifiedTime: "10"},
|
||||
}
|
||||
got, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile: %v", err)
|
||||
}
|
||||
if got.FileToken != "token_a" {
|
||||
t.Fatalf("fallback token = %q, want token_a", got.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
|
||||
_, err := chooseRemoteFile(nil, driveDuplicateRemoteNewest)
|
||||
if err == nil {
|
||||
t.Fatal("expected chooseRemoteFile to reject empty candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
|
||||
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
|
||||
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},
|
||||
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDSecond},
|
||||
}, "mystery")
|
||||
if err == nil {
|
||||
t.Fatal("expected drivePullRemoteViews to reject an unknown duplicate strategy")
|
||||
}
|
||||
}
|
||||
|
||||
func registerDuplicateRemoteFiles(reg *httpmock.Registry) {
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
}
|
||||
|
||||
func registerRemoteListing(reg *httpmock.Registry, folderToken string, files []map[string]interface{}) {
|
||||
items := make([]interface{}, 0, len(files))
|
||||
for _, file := range files {
|
||||
items = append(items, file)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=" + folderToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": items,
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerDownload(reg *httpmock.Registry, fileToken, body string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/" + fileToken + "/download",
|
||||
Status: 200,
|
||||
Body: []byte(body),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
}
|
||||
|
||||
func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate_remote_path error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
|
||||
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
|
||||
}
|
||||
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
|
||||
}
|
||||
duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath)
|
||||
if !ok {
|
||||
t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"])
|
||||
}
|
||||
if len(duplicates) == 0 {
|
||||
t.Fatal("duplicate detail should include at least one rel_path group")
|
||||
}
|
||||
if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey {
|
||||
t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap)
|
||||
}
|
||||
var matched bool
|
||||
for _, duplicate := range duplicates {
|
||||
if duplicate.RelPath != relPath {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
if len(duplicate.Entries) != len(tokens) {
|
||||
t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath)
|
||||
}
|
||||
for i, token := range tokens {
|
||||
if duplicate.Entries[i].FileToken != token {
|
||||
t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token)
|
||||
}
|
||||
if duplicate.Entries[i].Type == "" {
|
||||
t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
|
||||
}
|
||||
raw, marshalErr := json.Marshal(exitErr.Detail.Detail)
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal detail: %v", marshalErr)
|
||||
}
|
||||
text := string(raw)
|
||||
if !strings.Contains(text, relPath) {
|
||||
t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text)
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !strings.Contains(text, token) {
|
||||
t.Fatalf("duplicate detail missing token %q: %s", token, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type drivePullStdoutPayload struct {
|
||||
Data struct {
|
||||
Summary struct {
|
||||
Downloaded int `json:"downloaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
} `json:"summary"`
|
||||
Items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeDrivePullStdout(t *testing.T, raw []byte) drivePullStdoutPayload {
|
||||
t.Helper()
|
||||
var payload drivePullStdoutPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func findPullItem(items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
}, relPath string) struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
} {
|
||||
for _, item := range items {
|
||||
if item.RelPath == relPath {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
}{}
|
||||
}
|
||||
|
||||
func expectedRenamedRelPath(relPath, fileToken string, hashLen, attempt int) string {
|
||||
sum := sha256.Sum256([]byte(fileToken))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
suffix := "__lark_" + hash[:hashLen]
|
||||
if attempt > 0 {
|
||||
suffix = "__lark_" + hash + "_" + strconv.Itoa(attempt)
|
||||
}
|
||||
dir, base := path.Split(relPath)
|
||||
ext := path.Ext(base)
|
||||
if ext == base {
|
||||
return dir + base + suffix
|
||||
}
|
||||
stem := base[:len(base)-len(ext)]
|
||||
return dir + stem + suffix + ext
|
||||
}
|
||||
|
||||
func assertPullItemAction(t *testing.T, raw []byte, relPath, action string) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
|
||||
}
|
||||
for _, item := range payload.Data.Items {
|
||||
if item.RelPath == relPath && item.Action == action {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing pull item %q/%q in stdout: %s", relPath, action, string(raw))
|
||||
}
|
||||
|
||||
func assertPushItemAction(t *testing.T, raw []byte, relPath, action, fileToken string) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
FileToken string `json:"file_token"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode push stdout: %v\n%s", err, string(raw))
|
||||
}
|
||||
for _, item := range payload.Data.Items {
|
||||
if item.RelPath == relPath && item.Action == action && item.FileToken == fileToken {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing push item %q/%q/%q in stdout: %s", relPath, action, fileToken, string(raw))
|
||||
}
|
||||
@@ -28,10 +28,17 @@ const (
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type drivePullTarget struct {
|
||||
DownloadToken string
|
||||
ItemFileToken string
|
||||
ItemSourceID string
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
// a local directory: recursively lists --folder-token, downloads each
|
||||
// type=file entry under --local-dir, and optionally deletes local files
|
||||
@@ -54,12 +61,14 @@ var DrivePull = common.Shortcut{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
|
||||
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
|
||||
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -102,6 +111,10 @@ var DrivePull = common.Shortcut{
|
||||
if ifExists == "" {
|
||||
ifExists = drivePullIfExistsOverwrite
|
||||
}
|
||||
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
|
||||
if duplicateRemote == "" {
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
deleteLocal := runtime.Bool("delete-local")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before we
|
||||
@@ -132,10 +145,13 @@ var DrivePull = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives the download/skip loop (only type=file
|
||||
// has hashable bytes the local mirror can write back).
|
||||
@@ -143,13 +159,9 @@ var DrivePull = common.Shortcut{
|
||||
// rel_path Drive owns regardless of type, so a local file
|
||||
// shadowed by a remote folder / online doc / shortcut is NOT
|
||||
// treated as orphaned.
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
for rel, entry := range entries {
|
||||
remotePaths[rel] = struct{}{}
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
@@ -164,7 +176,10 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
token := remoteFiles[rel]
|
||||
targetFile := remoteFiles[rel]
|
||||
downloadToken := targetFile.DownloadToken
|
||||
itemFileToken := targetFile.ItemFileToken
|
||||
itemSourceID := targetFile.ItemSourceID
|
||||
target := filepath.Join(rootRelToCwd, rel)
|
||||
|
||||
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
|
||||
@@ -178,7 +193,8 @@ var DrivePull = common.Shortcut{
|
||||
if info.IsDir() {
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: token,
|
||||
FileToken: itemFileToken,
|
||||
SourceID: itemSourceID,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
@@ -187,19 +203,19 @@ var DrivePull = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
|
||||
downloaded++
|
||||
}
|
||||
|
||||
@@ -307,6 +323,66 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) {
|
||||
remoteFiles := make(map[string]drivePullTarget, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
fileGroups := make(map[string][]driveRemoteEntry)
|
||||
occupied := occupiedRemotePaths(entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
|
||||
continue
|
||||
}
|
||||
remotePaths[entry.RelPath] = struct{}{}
|
||||
}
|
||||
|
||||
relPaths := make([]string, 0, len(fileGroups))
|
||||
for rel := range fileGroups {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
|
||||
for _, rel := range relPaths {
|
||||
files := fileGroups[rel]
|
||||
if len(files) == 1 {
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken}
|
||||
remotePaths[rel] = struct{}{}
|
||||
continue
|
||||
}
|
||||
switch duplicateRemote {
|
||||
case driveDuplicateRemoteRename:
|
||||
candidates := append([]driveRemoteEntry(nil), files...)
|
||||
sortRemoteFiles(candidates, driveDuplicateRemoteOldest)
|
||||
for idx, file := range candidates {
|
||||
targetRel := rel
|
||||
if idx > 0 {
|
||||
var err error
|
||||
targetRel, err = relPathWithUniqueFileTokenSuffix(rel, file.FileToken, occupied)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
remoteFiles[targetRel] = drivePullTarget{
|
||||
DownloadToken: file.FileToken,
|
||||
ItemSourceID: stableTokenIdentifier(file.FileToken),
|
||||
}
|
||||
remotePaths[targetRel] = struct{}{}
|
||||
}
|
||||
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
|
||||
chosen, err := chooseRemoteFile(files, duplicateRemote)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken}
|
||||
remotePaths[rel] = struct{}{}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
}
|
||||
}
|
||||
return remoteFiles, remotePaths, nil
|
||||
}
|
||||
|
||||
// drivePullWalkLocal walks the canonical absolute root and returns the
|
||||
// absolute paths of every regular file underneath it. The caller deletes
|
||||
// some of these paths, so it is critical that they are produced by
|
||||
|
||||
@@ -293,6 +293,49 @@ func TestDrivePullPaginationHandlesPageTokenField(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullRenameSummarizesDuplicateDownloadsAndAvoidsRawTokenInRelPath(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
|
||||
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--on-duplicate-remote", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
|
||||
payload := decodeDrivePullStdout(t, stdout.Bytes())
|
||||
if got := payload.Data.Summary.Downloaded; got != 2 {
|
||||
t.Fatalf("summary.downloaded = %d, want 2", got)
|
||||
}
|
||||
if out := stdout.String(); strings.Contains(out, duplicateRemoteFileIDSecond) {
|
||||
t.Fatalf("stdout should not expose the raw duplicate file token in rename mode, got: %s", out)
|
||||
}
|
||||
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
|
||||
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
|
||||
}
|
||||
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
|
||||
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
|
||||
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
|
||||
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDrivePullDeleteLocalRequiresYes verifies the upfront safety guard:
|
||||
// --delete-local without --yes must be rejected before any API call.
|
||||
func TestDrivePullDeleteLocalRequiresYes(t *testing.T) {
|
||||
|
||||
@@ -92,12 +92,14 @@ var DrivePush = common.Shortcut{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
||||
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
||||
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
|
||||
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
||||
@@ -164,6 +166,10 @@ var DrivePush = common.Shortcut{
|
||||
// rolling-out upload_all `file_token`/`version` protocol field.
|
||||
ifExists = drivePushIfExistsSkip
|
||||
}
|
||||
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
|
||||
if duplicateRemote == "" {
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
deleteRemote := runtime.Bool("delete-remote")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
@@ -190,10 +196,13 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives upload / overwrite / orphan-delete
|
||||
// decisions (only type=file entries are upload candidates;
|
||||
@@ -203,15 +212,9 @@ var DrivePush = common.Shortcut{
|
||||
// path skip create_folder when an intermediate folder already
|
||||
// exists, and keeps directory recreation idempotent across
|
||||
// reruns.
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
for rel, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
remoteFiles[rel] = entry
|
||||
case driveTypeFolder:
|
||||
remoteFolders[rel] = entry
|
||||
}
|
||||
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
@@ -333,24 +336,31 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
if deleteRemote && !uploadFailed {
|
||||
// Stable iteration order so failures (and tests) are deterministic.
|
||||
remoteRelPaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
remoteRelPaths := make([]string, 0, len(remoteFileGroups))
|
||||
for p := range remoteFileGroups {
|
||||
remoteRelPaths = append(remoteRelPaths, p)
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
for _, rel := range remoteRelPaths {
|
||||
keepToken := ""
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
continue
|
||||
if chosen, ok := remoteFiles[rel]; ok {
|
||||
keepToken = chosen.FileToken
|
||||
}
|
||||
}
|
||||
entry := remoteFiles[rel]
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
for _, entry := range remoteFileGroups[rel] {
|
||||
if entry.FileToken == keepToken {
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +473,46 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
fileGroups := make(map[string][]driveRemoteEntry)
|
||||
|
||||
for _, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
|
||||
case driveTypeFolder:
|
||||
remoteFolders[entry.RelPath] = entry
|
||||
}
|
||||
}
|
||||
|
||||
relPaths := make([]string, 0, len(fileGroups))
|
||||
for rel := range fileGroups {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
|
||||
for _, rel := range relPaths {
|
||||
files := fileGroups[rel]
|
||||
if len(files) == 1 {
|
||||
remoteFiles[rel] = files[0]
|
||||
continue
|
||||
}
|
||||
switch duplicateRemote {
|
||||
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
|
||||
chosen, err := chooseRemoteFile(files, duplicateRemote)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
remoteFiles[rel] = chosen
|
||||
default:
|
||||
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
}
|
||||
}
|
||||
return remoteFiles, remoteFolders, fileGroups, nil
|
||||
}
|
||||
|
||||
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
|
||||
// folder identified by rootFolderToken) exists on Drive, creating any
|
||||
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
|
||||
|
||||
@@ -454,6 +454,124 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "dup-new-token",
|
||||
"version": "v99",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"uploaded": 1`) {
|
||||
t.Fatalf("expected uploaded=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"deleted_remote": 1`) {
|
||||
t.Fatalf("expected deleted_remote=1, got: %s", out)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for the unchosen duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteDeletesEntireDuplicateGroupWithoutLocalCounterpart(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerDuplicateRemoteFiles(reg)
|
||||
deleteFirst := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
deleteSecond := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteFirst)
|
||||
reg.Register(deleteSecond)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "skip",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"uploaded": 0`) {
|
||||
t.Fatalf("expected uploaded=0, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"deleted_remote": 2`) {
|
||||
t.Fatalf("expected deleted_remote=2, got: %s", out)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
|
||||
if deleteFirst.CapturedHeaders == nil || deleteSecond.CapturedHeaders == nil {
|
||||
t.Fatal("expected both duplicate remote DELETE requests to be issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces
|
||||
// the proper flag name in the error message.
|
||||
func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
|
||||
@@ -118,19 +118,22 @@ var DriveStatus = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 {
|
||||
return duplicateRemotePathError(duplicates)
|
||||
}
|
||||
// +status only diffs binary content, so collapse the unified
|
||||
// listing to type=file. Online docs / shortcuts have no
|
||||
// hashable bytes and are intentionally absent from the diff
|
||||
// view (a docx living next to a same-named local file is a
|
||||
// known no-op).
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
for rel, entry := range entries {
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
remoteFiles[entry.RelPath] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,37 @@ func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusFailsOnRemoteFileFolderConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
|
||||
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
|
||||
})
|
||||
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
|
||||
{"token": "nested-file-token", "name": "child.txt", "type": "file", "size": 1, "created_time": "3", "modified_time": "3"},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -5,8 +5,14 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -14,52 +20,63 @@ const (
|
||||
driveListRemotePageSize = 200
|
||||
driveTypeFile = "file"
|
||||
driveTypeFolder = "folder"
|
||||
driveUniqueSuffixMaxSeq = 1024
|
||||
)
|
||||
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolderEntries. It
|
||||
// carries enough metadata for every shortcut that consumes the listing
|
||||
// to build its own per-shortcut view by filtering on Type.
|
||||
type driveRemoteEntry struct {
|
||||
// FileToken is the Drive token for this entry. For type=folder this
|
||||
// is the folder_token; for everything else it is the file_token.
|
||||
FileToken string
|
||||
Name string
|
||||
Size int64
|
||||
// Type is the Drive entry kind verbatim from the API:
|
||||
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
|
||||
// "mindnote" | "slides" | "shortcut" | …
|
||||
Type string
|
||||
Type string
|
||||
CreatedTime string
|
||||
ModifiedTime string
|
||||
// RelPath is the entry's path relative to the listing root. Encoded
|
||||
// with "/" separators on every platform so it matches the rel_paths
|
||||
// produced by the shortcuts' local walkers.
|
||||
RelPath string
|
||||
}
|
||||
|
||||
// listRemoteFolder recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item, keyed by rel_path. Subfolders are
|
||||
// descended into and the folder's own entry is also recorded — callers
|
||||
// can reason about "this rel_path is occupied by a folder" without
|
||||
// re-listing.
|
||||
type driveDuplicateRemoteEntry struct {
|
||||
FileToken string `json:"file_token"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
CreatedTime string `json:"created_time,omitempty"`
|
||||
ModifiedTime string `json:"modified_time,omitempty"`
|
||||
}
|
||||
|
||||
type driveDuplicateRemotePath struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Entries []driveDuplicateRemoteEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// listRemoteFolderEntries recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item. Subfolders are descended into and the
|
||||
// folder's own entry is also recorded, allowing callers to detect multiple
|
||||
// remote files that map to the same rel_path.
|
||||
//
|
||||
// This is the shared backbone for the three sync-disk shortcuts. None
|
||||
// of them need every field at every call site, so each one filters
|
||||
// on Type:
|
||||
// The helper deliberately stores every Drive object kind. Online docs and
|
||||
// shortcuts are skipped by sync shortcuts later, but preserving their rel_path
|
||||
// here prevents destructive mirror modes from treating a local same-named
|
||||
// regular file as an orphan when Drive already owns that path.
|
||||
//
|
||||
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
|
||||
// to drive content-hash diffs against the local tree.
|
||||
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
|
||||
// download set, and the full key set (every rel_path) as the
|
||||
// guard for --delete-local.
|
||||
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
|
||||
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
|
||||
// for the create_folder cache.
|
||||
//
|
||||
// Pagination uses common.PaginationMeta, which accepts both
|
||||
// page_token and next_page_token — the Drive list endpoint has
|
||||
// historically returned the latter, but the helper future-proofs
|
||||
// against a backend rename.
|
||||
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
|
||||
out := make(map[string]driveRemoteEntry)
|
||||
// Pagination uses common.PaginationMeta, which accepts both page_token and
|
||||
// next_page_token.
|
||||
func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) ([]driveRemoteEntry, error) {
|
||||
var out []driveRemoteEntry
|
||||
pageToken := ""
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"folder_token": folderToken,
|
||||
"page_size": fmt.Sprint(driveListRemotePageSize),
|
||||
@@ -84,15 +101,24 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
|
||||
continue
|
||||
}
|
||||
rel := joinRelDrive(relBase, fName)
|
||||
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
|
||||
out = append(out, driveRemoteEntry{
|
||||
FileToken: fToken,
|
||||
Name: fName,
|
||||
Size: int64(common.GetFloat(f, "size")),
|
||||
Type: fType,
|
||||
CreatedTime: common.GetString(f, "created_time"),
|
||||
ModifiedTime: common.GetString(f, "modified_time"),
|
||||
RelPath: rel,
|
||||
})
|
||||
if fType == driveTypeFolder {
|
||||
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub, err := listRemoteFolderEntries(ctx, runtime, fToken, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range sub {
|
||||
out[k] = v
|
||||
}
|
||||
out = append(out, sub...)
|
||||
}
|
||||
}
|
||||
hasMore, nextToken := common.PaginationMeta(result)
|
||||
@@ -104,6 +130,208 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemotePath {
|
||||
groups := make(map[string][]driveRemoteEntry)
|
||||
for _, entry := range entries {
|
||||
groups[entry.RelPath] = append(groups[entry.RelPath], entry)
|
||||
}
|
||||
|
||||
relPaths := make([]string, 0, len(groups))
|
||||
for relPath, grouped := range groups {
|
||||
if len(grouped) > 1 {
|
||||
relPaths = append(relPaths, relPath)
|
||||
}
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
|
||||
duplicates := make([]driveDuplicateRemotePath, 0, len(relPaths))
|
||||
for _, relPath := range relPaths {
|
||||
grouped := append([]driveRemoteEntry(nil), groups[relPath]...)
|
||||
sort.SliceStable(grouped, func(i, j int) bool {
|
||||
if grouped[i].Type != grouped[j].Type {
|
||||
return grouped[i].Type < grouped[j].Type
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(grouped[i].CreatedTime, grouped[j].CreatedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(grouped[i].ModifiedTime, grouped[j].ModifiedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
return grouped[i].FileToken < grouped[j].FileToken
|
||||
})
|
||||
dupEntries := make([]driveDuplicateRemoteEntry, 0, len(grouped))
|
||||
for _, entry := range grouped {
|
||||
dupEntries = append(dupEntries, driveDuplicateRemoteEntry{
|
||||
FileToken: entry.FileToken,
|
||||
Name: entry.Name,
|
||||
Type: entry.Type,
|
||||
Size: entry.Size,
|
||||
CreatedTime: entry.CreatedTime,
|
||||
ModifiedTime: entry.ModifiedTime,
|
||||
})
|
||||
}
|
||||
duplicates = append(duplicates, driveDuplicateRemotePath{RelPath: relPath, Entries: dupEntries})
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "duplicate_remote_path",
|
||||
Message: "multiple Drive entries map to the same rel_path",
|
||||
Detail: map[string]interface{}{
|
||||
"duplicates_remote": duplicates,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
driveDuplicateRemoteFail = "fail"
|
||||
driveDuplicateRemoteRename = "rename"
|
||||
driveDuplicateRemoteNewest = "newest"
|
||||
driveDuplicateRemoteOldest = "oldest"
|
||||
)
|
||||
|
||||
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
a, b := files[i], files[j]
|
||||
switch strategy {
|
||||
case driveDuplicateRemoteNewest:
|
||||
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
|
||||
return cmp > 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
|
||||
return cmp > 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
default:
|
||||
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
|
||||
return cmp < 0
|
||||
} else if !ok {
|
||||
return a.FileToken < b.FileToken
|
||||
}
|
||||
}
|
||||
return a.FileToken < b.FileToken
|
||||
})
|
||||
}
|
||||
|
||||
func compareDriveTimes(a, b string) (int, bool) {
|
||||
av, aErr := strconv.ParseInt(a, 10, 64)
|
||||
bv, bErr := strconv.ParseInt(b, 10, 64)
|
||||
if aErr != nil || bErr != nil {
|
||||
return 0, false
|
||||
}
|
||||
switch {
|
||||
case av < bv:
|
||||
return -1, true
|
||||
case av > bv:
|
||||
return 1, true
|
||||
default:
|
||||
return 0, true
|
||||
}
|
||||
}
|
||||
|
||||
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
|
||||
if len(files) == 0 {
|
||||
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
|
||||
}
|
||||
candidates := append([]driveRemoteEntry(nil), files...)
|
||||
sortRemoteFiles(candidates, strategy)
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
func isFileOnlyDuplicatePath(duplicate driveDuplicateRemotePath) bool {
|
||||
if len(duplicate.Entries) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range duplicate.Entries {
|
||||
if entry.Type != driveTypeFile {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func blockingRemotePathConflicts(entries []driveRemoteEntry, duplicateRemote string) []driveDuplicateRemotePath {
|
||||
duplicates := duplicateRemoteFilePaths(entries)
|
||||
if duplicateRemote == driveDuplicateRemoteFail {
|
||||
return duplicates
|
||||
}
|
||||
blocking := make([]driveDuplicateRemotePath, 0, len(duplicates))
|
||||
for _, duplicate := range duplicates {
|
||||
if !isFileOnlyDuplicatePath(duplicate) {
|
||||
blocking = append(blocking, duplicate)
|
||||
}
|
||||
}
|
||||
return blocking
|
||||
}
|
||||
|
||||
func occupiedRemotePaths(entries []driveRemoteEntry) map[string]struct{} {
|
||||
occupied := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
occupied[entry.RelPath] = struct{}{}
|
||||
}
|
||||
return occupied
|
||||
}
|
||||
|
||||
func stableTokenHash(fileToken string) string {
|
||||
sum := sha256.Sum256([]byte(fileToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func stableTokenIdentifier(fileToken string) string {
|
||||
hash := stableTokenHash(fileToken)
|
||||
if len(hash) > 12 {
|
||||
hash = hash[:12]
|
||||
}
|
||||
return "hash_" + hash
|
||||
}
|
||||
|
||||
func relPathWithSuffix(relPath, suffix string) string {
|
||||
dir, base := path.Split(relPath)
|
||||
ext := path.Ext(base)
|
||||
if ext == base {
|
||||
return dir + base + suffix
|
||||
}
|
||||
stem := base[:len(base)-len(ext)]
|
||||
return dir + stem + suffix + ext
|
||||
}
|
||||
|
||||
func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[string]struct{}) (string, error) {
|
||||
tokenHash := stableTokenHash(fileToken)
|
||||
suffixes := []string{
|
||||
"__lark_" + tokenHash[:12],
|
||||
"__lark_" + tokenHash[:24],
|
||||
"__lark_" + tokenHash,
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
candidate := relPathWithSuffix(relPath, suffix)
|
||||
if _, exists := occupied[candidate]; !exists {
|
||||
occupied[candidate] = struct{}{}
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
|
||||
candidate := relPathWithSuffix(relPath, "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))
|
||||
if _, exists := occupied[candidate]; !exists {
|
||||
occupied[candidate] = struct{}{}
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
|
||||
}
|
||||
|
||||
// joinRelDrive joins a rel_path base with an entry name using "/".
|
||||
// Empty base means the entry sits at the listing root. Mirrors the
|
||||
// behavior the per-shortcut helpers used to ship and keeps rel_paths
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -32,6 +34,18 @@ var mentionFixRe = regexp.MustCompile(`<at\s+(id|open_id|user_id)=("?)([^"\s/>]+
|
||||
var threadIDRe = regexp.MustCompile(`^omt_`)
|
||||
var messageIDRe = regexp.MustCompile(`^om_`)
|
||||
|
||||
func flagMessageID(rt *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(rt.Str("message-id"))
|
||||
if id == "" {
|
||||
return "", output.ErrValidation("--message-id is required")
|
||||
}
|
||||
if strings.HasPrefix(id, "omt_") {
|
||||
return "", output.ErrValidation(
|
||||
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
|
||||
}
|
||||
return validateMessageID(id)
|
||||
}
|
||||
|
||||
func normalizeAtMentions(content string) string {
|
||||
return mentionFixRe.ReplaceAllString(content, `<at user_id="$3">`)
|
||||
}
|
||||
@@ -1432,3 +1446,222 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// FlagType enumerates the kind of bookmark.
|
||||
// Aligned with server-side constants: Unknown=0, Feed=1, Message=2.
|
||||
type FlagType int
|
||||
|
||||
const (
|
||||
FlagTypeUnknown FlagType = 0
|
||||
FlagTypeFeed FlagType = 1
|
||||
FlagTypeMessage FlagType = 2
|
||||
)
|
||||
|
||||
// ItemType enumerates the kind of thing being bookmarked.
|
||||
// Server-side constants (only the types used by IM flags):
|
||||
//
|
||||
// default=0, thread=4, msg_thread=11.
|
||||
//
|
||||
// Note on the two thread-shaped item types:
|
||||
// - ItemTypeThread (4) — thread inside a topic-style chat
|
||||
// - ItemTypeMsgThread (11) — thread inside a regular chat
|
||||
type ItemType int
|
||||
|
||||
const (
|
||||
ItemTypeDefault ItemType = 0
|
||||
ItemTypeThread ItemType = 4 // thread in a topic-style chat
|
||||
ItemTypeMsgThread ItemType = 11 // thread in a regular chat
|
||||
)
|
||||
|
||||
const (
|
||||
flagWriteScope = "im:feed.flag:write"
|
||||
flagReadScope = "im:feed.flag:read"
|
||||
)
|
||||
|
||||
var (
|
||||
flagWriteLookupScopes = append([]string{flagWriteScope}, flagLookupScopes...)
|
||||
flagMessageReadScopes = []string{
|
||||
"im:message.group_msg:get_as_user",
|
||||
"im:message.p2p_msg:get_as_user",
|
||||
}
|
||||
flagLookupScopes = []string{
|
||||
"im:message.group_msg:get_as_user",
|
||||
"im:message.p2p_msg:get_as_user",
|
||||
"im:chat:read",
|
||||
}
|
||||
)
|
||||
|
||||
func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, required []string) error {
|
||||
if len(required) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitAuth, "auth",
|
||||
fmt.Sprintf("cannot verify required scope(s): %v", err),
|
||||
flagScopeLoginHint(required))
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
fmt.Fprintf(rt.IO().ErrOut,
|
||||
"warning: cannot verify required scope(s) because token scope metadata is unavailable; API may fail if missing: %s\n",
|
||||
strings.Join(required, " "))
|
||||
return nil
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
flagScopeLoginHint(missing))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagScopeLoginHint(scopes []string) string {
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(scopes, " "))
|
||||
}
|
||||
|
||||
// flagItem is one entry in the flags API body. The server expects numeric
|
||||
// enums serialized as strings.
|
||||
type flagItem struct {
|
||||
ItemID string `json:"item_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
FlagType string `json:"flag_type"`
|
||||
}
|
||||
|
||||
// parseItemID inspects an om_ prefix and returns a best-guess
|
||||
// (itemType, flagType) pair. Used when the user omits the explicit enums.
|
||||
// - om_xxx → (default, message)
|
||||
func parseItemID(id string) (ItemType, FlagType, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
switch {
|
||||
case strings.HasPrefix(id, "om_"):
|
||||
return ItemTypeDefault, FlagTypeMessage, nil
|
||||
case id == "":
|
||||
return 0, 0, output.ErrValidation("--message-id cannot be empty")
|
||||
default:
|
||||
return 0, 0, output.ErrValidation(
|
||||
"cannot infer item type from id %q: expected om_ (message) prefix; "+
|
||||
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
|
||||
}
|
||||
}
|
||||
|
||||
// parseItemType converts a user-facing string to the server enum.
|
||||
func parseItemType(s string) (ItemType, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "default":
|
||||
return ItemTypeDefault, nil
|
||||
case "thread":
|
||||
return ItemTypeThread, nil
|
||||
case "msg_thread":
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
|
||||
}
|
||||
|
||||
// parseFlagType converts a user-facing string to the server enum.
|
||||
func parseFlagType(s string) (FlagType, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "message":
|
||||
return FlagTypeMessage, nil
|
||||
case "feed":
|
||||
return FlagTypeFeed, nil
|
||||
}
|
||||
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
|
||||
}
|
||||
|
||||
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
|
||||
// Note: (ItemType, FlagType) is shorthand for (item_type, flag_type) — the two
|
||||
// enum fields that determine which layer the flag operates on.
|
||||
//
|
||||
// Valid combinations are:
|
||||
// - (default, message) — regular chat message (message-layer flag)
|
||||
// - (thread, feed) — thread as feed-layer flag (topic-style chat)
|
||||
// - (msg_thread, feed) — message-thread as feed-layer flag (regular chat)
|
||||
func isValidCombo(it ItemType, ft FlagType) bool {
|
||||
return (it == ItemTypeDefault && ft == FlagTypeMessage) ||
|
||||
(it == ItemTypeThread && ft == FlagTypeFeed) ||
|
||||
(it == ItemTypeMsgThread && ft == FlagTypeFeed)
|
||||
}
|
||||
|
||||
// parseItemTypeFromRaw parses a stringified numeric item_type back to ItemType.
|
||||
// Used when re-parsing the serialized enum for combo-validity checks.
|
||||
// Note: Unknown values return ItemTypeDefault (0). This is safe because:
|
||||
// 1. This function only parses values we serialized ourselves via newFlagItem
|
||||
// 2. Unknown server values would fail combo validation or be rejected by the server
|
||||
func parseItemTypeFromRaw(s string) ItemType {
|
||||
switch s {
|
||||
case "0":
|
||||
return ItemTypeDefault
|
||||
case "4":
|
||||
return ItemTypeThread
|
||||
case "11":
|
||||
return ItemTypeMsgThread
|
||||
}
|
||||
return ItemTypeDefault
|
||||
}
|
||||
|
||||
// parseFlagTypeFromRaw parses a stringified numeric flag_type back to FlagType.
|
||||
// Used when re-parsing the serialized enum for combo-validity checks.
|
||||
func parseFlagTypeFromRaw(s string) FlagType {
|
||||
switch s {
|
||||
case "1":
|
||||
return FlagTypeFeed
|
||||
case "2":
|
||||
return FlagTypeMessage
|
||||
}
|
||||
return FlagTypeUnknown
|
||||
}
|
||||
|
||||
// newFlagItem builds a payload entry with numeric-stringified enums.
|
||||
func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
|
||||
return flagItem{
|
||||
ItemID: itemID,
|
||||
ItemType: fmt.Sprintf("%d", int(it)),
|
||||
FlagType: fmt.Sprintf("%d", int(ft)),
|
||||
}
|
||||
}
|
||||
|
||||
// getMessageChatID queries the message API to get the chat_id.
|
||||
// Used by flag-create to determine the chat type for feed-layer flags.
|
||||
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
items, ok := data["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return "", output.ErrValidation("message not found or unexpected API response format")
|
||||
}
|
||||
|
||||
msg, ok := items[0].(map[string]any)
|
||||
if !ok {
|
||||
return "", output.ErrValidation("unexpected message format in API response")
|
||||
}
|
||||
|
||||
chatID, ok := msg["chat_id"].(string)
|
||||
if !ok {
|
||||
return "", output.ErrValidation("message response missing chat_id field")
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// resolveThreadFeedItemType determines the correct feed-layer ItemType for a thread
|
||||
// by querying the chat API for chat_mode.
|
||||
// - topic-style chat → ItemTypeThread
|
||||
// - regular chat → ItemTypeMsgThread
|
||||
//
|
||||
// Returns an error if the chat query fails, since guessing the wrong item_type
|
||||
// can cause silent failures in flag operations.
|
||||
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
|
||||
if err != nil {
|
||||
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
|
||||
}
|
||||
|
||||
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
|
||||
chatMode, _ := data["chat_mode"].(string)
|
||||
if chatMode == "topic" {
|
||||
return ItemTypeThread, nil
|
||||
}
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
|
||||
@@ -868,6 +868,9 @@ func TestShortcuts(t *testing.T) {
|
||||
"+messages-search",
|
||||
"+messages-send",
|
||||
"+threads-messages-list",
|
||||
"+flag-create",
|
||||
"+flag-cancel",
|
||||
"+flag-list",
|
||||
}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
||||
|
||||
247
shortcuts/im/im_flag_cancel.go
Normal file
247
shortcuts/im/im_flag_cancel.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
|
||||
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
|
||||
var ImFlagCancel = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-cancel",
|
||||
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
|
||||
"performs double-cancel: removes both message and feed layers",
|
||||
Risk: "write",
|
||||
UserScopes: flagWriteLookupScopes,
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)"},
|
||||
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
|
||||
{Name: "flag-type", Desc: "flag type override: message|feed; omit to double-cancel both layers"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := buildCancelItemsForPreview(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
items, _, err := buildCancelItemsForPreview(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v1/flags/cancel").
|
||||
Body(map[string]any{"flag_items": items})
|
||||
if len(items) > 1 {
|
||||
d.Desc("double-cancel: tries both message and feed layers (best-effort); feed-layer skipped if chat_type undeterminable")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
items, err := buildCancelItems(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make separate API calls for each item so they are independent.
|
||||
// If one fails, the other can still succeed.
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
var lastErr error
|
||||
for _, item := range items {
|
||||
itemType := itemTypeString(parseItemTypeFromRaw(item.ItemType))
|
||||
flagType := flagTypeString(parseFlagTypeFromRaw(item.FlagType))
|
||||
result := map[string]any{
|
||||
"item_id": item.ItemID,
|
||||
"item_type": itemType,
|
||||
"flag_type": flagType,
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil,
|
||||
map[string]any{"flag_items": []flagItem{item}})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
|
||||
itemType, flagType, err)
|
||||
result["status"] = "failed"
|
||||
result["error"] = err.Error()
|
||||
lastErr = err
|
||||
} else {
|
||||
result["status"] = "ok"
|
||||
result["response"] = data
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]any{"results": results}, nil)
|
||||
return lastErr
|
||||
},
|
||||
}
|
||||
|
||||
// buildCancelItemsForPreview builds cancel items without API calls.
|
||||
// It shows double-cancel when no explicit flags are provided.
|
||||
// DryRun cannot query chat_mode, so feed-layer item_type is represented with
|
||||
// the same auto-detect placeholder used by +flag-create.
|
||||
func buildCancelItemsForPreview(rt *common.RuntimeContext) ([]any, bool, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
|
||||
// Explicit override provided → single targeted delete
|
||||
if itOverride != "" || ftOverride != "" {
|
||||
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return []any{item}, false, nil
|
||||
}
|
||||
|
||||
// No override: show double-cancel (message + feed layers)
|
||||
// Dry-run shows both layers; actual execution is best-effort.
|
||||
return []any{
|
||||
newFlagItem(id, ItemTypeDefault, FlagTypeMessage),
|
||||
map[string]string{
|
||||
"item_id": id,
|
||||
"item_type": "<auto:thread|msg_thread>",
|
||||
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
|
||||
},
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// buildCancelItems picks the (item_type, flag_type) pairs to cancel.
|
||||
//
|
||||
// Logic:
|
||||
// 1. If --flag-type is explicitly provided, do a single targeted delete.
|
||||
// 2. Otherwise, perform double-cancel: remove both message layer and feed layer.
|
||||
// - Message layer is always included (uses known message_id with ItemTypeDefault)
|
||||
// - Feed layer is best-effort: if chat_type cannot be determined, skip with warning
|
||||
// - Each layer is independent; failure to cancel one doesn't block the other
|
||||
func buildCancelItems(rt *common.RuntimeContext) ([]flagItem, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
|
||||
// Explicit override provided → single targeted delete
|
||||
if itOverride != "" || ftOverride != "" {
|
||||
item, err := buildSingleCancelItem(id, itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []flagItem{item}, nil
|
||||
}
|
||||
|
||||
// Double-cancel: message layer + feed layer (best effort)
|
||||
// Message layer is always included - we have the message_id and know the combo is valid.
|
||||
items := []flagItem{newFlagItem(id, ItemTypeDefault, FlagTypeMessage)}
|
||||
|
||||
// Feed layer: try to determine chat_type, but don't fail if we can't.
|
||||
// Most messages only have one layer flagged, so this is best-effort cleanup.
|
||||
chatID, err := getMessageChatID(rt, id)
|
||||
if err != nil {
|
||||
// Can't get chat_id, warn and skip feed layer
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
||||
if err != nil {
|
||||
// Can't determine chat_type, warn and skip feed layer
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: cannot determine feed-layer item_type: %v; skipping feed-layer cancel\n", err)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Include feed layer
|
||||
items = append(items, newFlagItem(id, feedIT, FlagTypeFeed))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// buildSingleCancelItem builds a single cancel item when user provides explicit flags.
|
||||
func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error) {
|
||||
var itemType ItemType
|
||||
var flagType FlagType
|
||||
|
||||
if itOverride != "" {
|
||||
it, err := parseItemType(itOverride)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
itemType = it
|
||||
}
|
||||
if ftOverride != "" {
|
||||
ft, err := parseFlagType(ftOverride)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
flagType = ft
|
||||
}
|
||||
if itOverride == "" || ftOverride == "" {
|
||||
inferIT, inferFT, err := parseItemID(id)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
if itOverride == "" {
|
||||
itemType = inferIT
|
||||
}
|
||||
if ftOverride == "" {
|
||||
flagType = inferFT
|
||||
}
|
||||
}
|
||||
if !isValidCombo(itemType, flagType) {
|
||||
// Provide more specific hints for common mistakes
|
||||
if itOverride != "" && ftOverride == "" {
|
||||
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)",
|
||||
itOverride)
|
||||
}
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override",
|
||||
itOverride, flagTypeString(flagType))
|
||||
}
|
||||
if itOverride == "" && ftOverride != "" {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override",
|
||||
ftOverride, itemTypeString(itemType))
|
||||
}
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
|
||||
}
|
||||
return newFlagItem(id, itemType, flagType), nil
|
||||
}
|
||||
|
||||
// itemTypeString converts ItemType to a user-facing string.
|
||||
func itemTypeString(it ItemType) string {
|
||||
switch it {
|
||||
case ItemTypeDefault:
|
||||
return "default"
|
||||
case ItemTypeThread:
|
||||
return "thread"
|
||||
case ItemTypeMsgThread:
|
||||
return "msg_thread"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// flagTypeString converts FlagType to a user-facing string.
|
||||
func flagTypeString(ft FlagType) string {
|
||||
switch ft {
|
||||
case FlagTypeFeed:
|
||||
return "feed"
|
||||
case FlagTypeMessage:
|
||||
return "message"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
212
shortcuts/im/im_flag_create.go
Normal file
212
shortcuts/im/im_flag_create.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFlagCreate provides the +flag-create shortcut for creating a bookmark on a message.
|
||||
var ImFlagCreate = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-create",
|
||||
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)",
|
||||
Risk: "write",
|
||||
UserScopes: flagWriteLookupScopes,
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "message ID (om_xxx)"},
|
||||
{Name: "item-type", Desc: "item type override: default|thread|msg_thread (rarely needed)"},
|
||||
{Name: "flag-type", Desc: "flag type: message (default) or feed"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildCreateItemForPreview(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
item, err := buildCreateItemForPreview(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v1/flags").
|
||||
Body(map[string]any{"flag_items": []any{item}})
|
||||
if m, ok := item.(map[string]string); ok && m["item_type"] == "<auto:thread|msg_thread>" {
|
||||
d.Desc("feed-layer item_type is auto-detected at execution time by reading the message chat and chat_mode")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
item, err := buildCreateItem(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Combo validation already done in Validate, but double-check as a safety net.
|
||||
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
|
||||
return output.ErrValidation(
|
||||
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
|
||||
"(default, message), (thread, feed), or (msg_thread, feed)",
|
||||
item.ItemType, item.FlagType)
|
||||
}
|
||||
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil,
|
||||
map[string]any{"flag_items": []flagItem{item}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildCreateItemForPreview derives a preview payload without making network calls.
|
||||
// Feed-layer execution auto-detects item_type from chat_mode, but dry-run must
|
||||
// not query the message or chat APIs, so it uses an explicit placeholder.
|
||||
func buildCreateItemForPreview(rt *common.RuntimeContext) (any, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flagType := FlagTypeMessage
|
||||
if combo.FlagTypeSet {
|
||||
flagType = combo.FlagType
|
||||
}
|
||||
if flagType == FlagTypeMessage {
|
||||
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet {
|
||||
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"item_id": id,
|
||||
"item_type": "<auto:thread|msg_thread>",
|
||||
"flag_type": fmt.Sprintf("%d", int(FlagTypeFeed)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildCreateItem derives a flagItem for the create path.
|
||||
//
|
||||
// Resolution logic:
|
||||
// 1. No --flag-type or --flag-type=message → (default, message)
|
||||
// 2. --flag-type=feed (no --item-type) → query message to get chat_id,
|
||||
// then query chat_mode to determine: topic-style → (thread, feed), regular → (msg_thread, feed)
|
||||
// 3. Both --item-type and --flag-type provided → honor verbatim (for edge cases)
|
||||
func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
|
||||
id, err := flagMessageID(rt)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
|
||||
itOverride := strings.TrimSpace(rt.Str("item-type"))
|
||||
ftOverride := strings.TrimSpace(rt.Str("flag-type"))
|
||||
combo, err := parseExplicitFlagCombo(itOverride, ftOverride)
|
||||
if err != nil {
|
||||
return flagItem{}, err
|
||||
}
|
||||
|
||||
flagType := FlagTypeMessage
|
||||
if combo.FlagTypeSet {
|
||||
flagType = combo.FlagType
|
||||
}
|
||||
|
||||
// Message-layer flag: always (default, message)
|
||||
if flagType == FlagTypeMessage {
|
||||
return newFlagItem(id, ItemTypeDefault, FlagTypeMessage), nil
|
||||
}
|
||||
|
||||
// Feed-layer flag: need to determine item_type from chat_mode
|
||||
if combo.ItemTypeSet {
|
||||
// User explicitly specified item-type, honor it
|
||||
return newFlagItem(id, combo.ItemType, FlagTypeFeed), nil
|
||||
}
|
||||
|
||||
chatID, err := getMessageChatID(rt, id)
|
||||
if err != nil {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
|
||||
}
|
||||
if chatID == "" {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"message does not belong to a chat; feed-layer flags are only for messages in chats")
|
||||
}
|
||||
|
||||
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
||||
if err != nil {
|
||||
return flagItem{}, output.ErrValidation(
|
||||
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
|
||||
}
|
||||
return newFlagItem(id, feedIT, FlagTypeFeed), nil
|
||||
}
|
||||
|
||||
type explicitFlagCombo struct {
|
||||
ItemType ItemType
|
||||
FlagType FlagType
|
||||
ItemTypeSet bool
|
||||
FlagTypeSet bool
|
||||
}
|
||||
|
||||
func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, error) {
|
||||
itOverride = strings.TrimSpace(itOverride)
|
||||
ftOverride = strings.TrimSpace(ftOverride)
|
||||
|
||||
var combo explicitFlagCombo
|
||||
if itOverride != "" {
|
||||
it, err := parseItemType(itOverride)
|
||||
if err != nil {
|
||||
return explicitFlagCombo{}, err
|
||||
}
|
||||
combo.ItemType = it
|
||||
combo.ItemTypeSet = true
|
||||
}
|
||||
if ftOverride != "" {
|
||||
ft, err := parseFlagType(ftOverride)
|
||||
if err != nil {
|
||||
return explicitFlagCombo{}, err
|
||||
}
|
||||
combo.FlagType = ft
|
||||
combo.FlagTypeSet = true
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet && !combo.FlagTypeSet {
|
||||
switch combo.ItemType {
|
||||
case ItemTypeThread, ItemTypeMsgThread:
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
|
||||
case ItemTypeDefault:
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
|
||||
}
|
||||
}
|
||||
|
||||
if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) {
|
||||
return explicitFlagCombo{}, output.ErrValidation(
|
||||
"invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed",
|
||||
itOverride, ftOverride)
|
||||
}
|
||||
|
||||
return combo, nil
|
||||
}
|
||||
|
||||
// validateExplicitCombo validates the (item_type, flag_type) combination when
|
||||
// the user explicitly provides flags. It does not make API calls - it only
|
||||
// validates the logic for what the user explicitly specified.
|
||||
func validateExplicitCombo(itOverride, ftOverride string) error {
|
||||
_, err := parseExplicitFlagCombo(itOverride, ftOverride)
|
||||
return err
|
||||
}
|
||||
300
shortcuts/im/im_flag_list.go
Normal file
300
shortcuts/im/im_flag_list.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
|
||||
// Feed-type thread entries are auto-enriched with message content.
|
||||
var ImFlagList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-list",
|
||||
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{flagReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
{Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateListOptions(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if err := validateListOptions(runtime); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
GET("/open-apis/im/v1/flags").
|
||||
Params(map[string]any{
|
||||
"page_size": strconv.Itoa(runtime.Int("page-size")),
|
||||
"page_token": runtime.Str("page-token"),
|
||||
})
|
||||
if runtime.Bool("enrich-feed-thread") {
|
||||
d.Desc("conditional enrichment: if feed/thread flag items are missing message content, execution may also call GET /open-apis/im/v1/messages/mget and requires scopes im:message.group_msg:get_as_user im:message.p2p_msg:get_as_user; pass --enrich-feed-thread=false to skip this extra call and extra scopes")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// When --page-token is explicitly provided, the user wants a specific page —
|
||||
// no auto-pagination regardless of --page-all.
|
||||
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
|
||||
return executeListAllPages(runtime)
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("enrich-feed-thread") {
|
||||
if err := enrichFeedThreadItems(runtime, data); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateListOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 50")
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return output.ErrValidation("--page-limit must be an integer between 1 and 1000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listQuery builds the query parameters for the flag list API call.
|
||||
// page_token is required by the server even on the first page — pass empty
|
||||
// string when the user hasn't supplied one.
|
||||
func listQuery(rt *common.RuntimeContext) larkcore.QueryParams {
|
||||
return larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{rt.Str("page-token")},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichFeedThreadItems attaches message body to feed-shape thread entries
|
||||
// by calling messages/mget. The list API returns only IDs for feed-shape entries,
|
||||
// so this enrichment is needed to provide full message content.
|
||||
//
|
||||
// NOTE: This function modifies data["flag_items"] in place by adding a "message" key
|
||||
// to each feed-thread entry.
|
||||
func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error {
|
||||
// Only enrich active flags (flag_items), not canceled flags (delete_flag_items).
|
||||
// Canceled message-type flags don't show message content, so thread-type flags don't need it either.
|
||||
items, _ := data["flag_items"].([]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index any messages the server already returned — saves a mget round-trip
|
||||
// (ItemType=default+FlagType=Message responses already carry the message body).
|
||||
byID := make(map[string]map[string]any)
|
||||
if inline, ok := data["messages"].([]any); ok {
|
||||
for _, m := range inline {
|
||||
mm, _ := m.(map[string]any)
|
||||
if mm == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(mm["message_id"]); id != "" {
|
||||
byID[id] = mm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect feed-thread ids whose message body wasn't inlined — dedup to cut mget calls.
|
||||
need := map[string]bool{}
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ft := asString(m["flag_type"])
|
||||
itStr := asString(m["item_type"])
|
||||
if ft != strconv.Itoa(int(FlagTypeFeed)) {
|
||||
continue
|
||||
}
|
||||
if itStr != strconv.Itoa(int(ItemTypeThread)) && itStr != strconv.Itoa(int(ItemTypeMsgThread)) {
|
||||
continue
|
||||
}
|
||||
id := asString(m["item_id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, inlined := byID[id]; !inlined {
|
||||
need[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(need) > 0 {
|
||||
if err := checkFlagRequiredScopes(rt.Ctx(), rt, flagMessageReadScopes); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]string, 0, len(need))
|
||||
for id := range need {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// /messages/mget accepts max 50 IDs per request — batch if needed.
|
||||
const mgetBatchSize = 50
|
||||
for i := 0; i < len(ids); i += mgetBatchSize {
|
||||
end := i + mgetBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
batch := ids[i:end]
|
||||
got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget",
|
||||
larkcore.QueryParams{"message_ids": batch}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fetched, _ := got["items"].([]any)
|
||||
for _, m := range fetched {
|
||||
mm, _ := m.(map[string]any)
|
||||
if mm == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(mm["message_id"]); id != "" {
|
||||
byID[id] = mm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(byID) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Attach message payload to the matching list entries.
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
ft := asString(m["flag_type"])
|
||||
itType := asString(m["item_type"])
|
||||
if ft != strconv.Itoa(int(FlagTypeFeed)) {
|
||||
continue
|
||||
}
|
||||
if itType != strconv.Itoa(int(ItemTypeThread)) && itType != strconv.Itoa(int(ItemTypeMsgThread)) {
|
||||
continue
|
||||
}
|
||||
if msg, ok := byID[asString(m["item_id"])]; ok {
|
||||
m["message"] = msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// asString converts an arbitrary value to its string representation.
|
||||
// Handles string, float64, int, int64, and json.Number types; returns empty string for other types.
|
||||
func asString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// executeListAllPages fetches all pages and merges the results into a single response.
|
||||
// The flag list API returns items sorted by update_time ascending, so the last page
|
||||
// contains the newest items.
|
||||
func executeListAllPages(rt *common.RuntimeContext) error {
|
||||
maxPages := rt.Int("page-limit")
|
||||
if maxPages < 1 {
|
||||
maxPages = 20
|
||||
}
|
||||
if maxPages > 1000 {
|
||||
maxPages = 1000
|
||||
}
|
||||
|
||||
// Use make([]any, 0) to ensure empty arrays serialize as [] not null
|
||||
allFlagItems := make([]any, 0)
|
||||
allDeleteFlagItems := make([]any, 0)
|
||||
allMessages := make([]any, 0)
|
||||
var lastHasMore bool
|
||||
var lastPageToken string
|
||||
prevPageToken := "__START__" // Sentinel to detect unchanged token
|
||||
|
||||
for page := 0; page < maxPages; page++ {
|
||||
token := ""
|
||||
if page > 0 {
|
||||
token = lastPageToken
|
||||
}
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
|
||||
larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{token},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v, ok := data["flag_items"].([]any); ok {
|
||||
allFlagItems = append(allFlagItems, v...)
|
||||
}
|
||||
if v, ok := data["delete_flag_items"].([]any); ok {
|
||||
allDeleteFlagItems = append(allDeleteFlagItems, v...)
|
||||
}
|
||||
if v, ok := data["messages"].([]any); ok {
|
||||
allMessages = append(allMessages, v...)
|
||||
}
|
||||
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
|
||||
// Progress output to stderr
|
||||
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d flags, %d deleted\n",
|
||||
page+1, len(allFlagItems), len(allDeleteFlagItems))
|
||||
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
// Detect server anomaly: same token returned twice means infinite loop
|
||||
if lastPageToken == prevPageToken {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
|
||||
break
|
||||
}
|
||||
prevPageToken = lastPageToken
|
||||
}
|
||||
|
||||
merged := map[string]any{
|
||||
"flag_items": allFlagItems,
|
||||
"delete_flag_items": allDeleteFlagItems,
|
||||
"messages": allMessages,
|
||||
"has_more": lastHasMore,
|
||||
"page_token": lastPageToken,
|
||||
}
|
||||
|
||||
if rt.Bool("enrich-feed-thread") {
|
||||
if err := enrichFeedThreadItems(rt, merged); err != nil {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: feed-thread enrichment failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
rt.Out(merged, nil)
|
||||
return nil
|
||||
}
|
||||
1812
shortcuts/im/im_flag_test.go
Normal file
1812
shortcuts/im/im_flag_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,5 +18,8 @@ func Shortcuts() []common.Shortcut {
|
||||
ImMessagesSearch,
|
||||
ImMessagesSend,
|
||||
ImThreadsMessagesList,
|
||||
ImFlagCreate,
|
||||
ImFlagCancel,
|
||||
ImFlagList,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
|
||||
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
|
||||
- **Reaction**: An emoji reaction on a message.
|
||||
- **Flag**: A bookmark on a message or thread.
|
||||
|
||||
## Resource Relationships
|
||||
|
||||
@@ -35,3 +36,14 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
|
||||
### Card Messages (Interactive)
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
### Flag Types
|
||||
|
||||
Flags support two layers:
|
||||
|
||||
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
|
||||
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
|
||||
|
||||
Item types for feed-layer flags:
|
||||
- **ItemTypeThread** (4) = thread in a topic-style chat
|
||||
- **ItemTypeMsgThread** (11) = thread in a regular chat
|
||||
|
||||
@@ -229,8 +229,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Duplicate remote `rel_path` conflicts fail by default before writing; for duplicate files only, `--on-duplicate-remote rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` explicitly choose one. Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
|
||||
@@ -238,7 +238,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
|
||||
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Duplicate remote `rel_path` conflicts fail by default before upload / overwrite / delete; use `--on-duplicate-remote newest\|oldest` only when the conflict is duplicate files and you explicitly want to target one existing remote file. Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
|
||||
@@ -15,10 +15,23 @@
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.failed` | 下载或写盘失败的文件数 |
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `action` / 失败时的 `error`) |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) |
|
||||
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`,`error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
| `fail` | 默认。返回所有冲突条目的完整信息,不写盘 |
|
||||
| `rename` | 仅适用于 duplicate file。下载全部重复文件;第一个保留原名,后续文件使用稳定 hash 后缀生成唯一文件名;若短后缀目标已被占用,会自动升级到更强后缀 |
|
||||
| `newest` | 只下载 `modified_time` 最新的远端文件 |
|
||||
| `oldest` | 只下载 `created_time` 最早的远端文件 |
|
||||
|
||||
`rename` 命名规则稳定且可追溯:`report.pdf` 的后续重复项会落盘为 `report__lark_<hash>.pdf`,例如 `report__lark_3a2f4c5d6e7f.pdf`。如果这个短 hash 目标名已经被同目录下的其他远端对象占用,CLI 会自动改用更长的稳定 hash,必要时再追加序号后缀,直到目标名唯一。此模式下 `items[]` 不再返回可直接复用的 Drive `file_token`;CLI 会在 `source_id` 中返回稳定 hash 标识符,供日志、比对和人工排查使用。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -29,6 +42,10 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists skip
|
||||
|
||||
# 云端有多个同名二进制文件时,显式下载全部并用稳定 hash 后缀改名
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--on-duplicate-remote rename
|
||||
|
||||
# 文件级镜像:下载新文件 + 删除云端没有的本地文件(不删空目录)
|
||||
# (--delete-local 必须搭配 --yes,否则会被 Validate 直接拒绝)
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
@@ -42,6 +59,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` |
|
||||
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `rename` / `newest` / `oldest` |
|
||||
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"(**不删空目录**,因此是 file-level mirror);**必须配合 `--yes`** |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
|
||||
@@ -50,6 +68,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
|
||||
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。
|
||||
|
||||
## --delete-local 的安全行为
|
||||
|
||||
@@ -58,6 +77,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
|
||||
- `--delete-local --yes`,**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-local` → `summary.deleted_local` 永远是 0;命令对本地"多余"文件视而不见。
|
||||
|
||||
第 6 章里把 `+pull --delete-local` 标了 `high-risk-write`,CLI 这边的实现等价于"未传 `--yes` 时拒绝执行",符合该约束的精神。
|
||||
@@ -74,15 +94,15 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "downloaded"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."},
|
||||
{"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "downloaded"},
|
||||
{"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "failed", "error": "..."},
|
||||
{"rel_path": "...", "action": "deleted_local"},
|
||||
{"rel_path": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`,因为该文件本来就只在本地。
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`。`rename` 模式下,duplicate 文件条目会返回 `source_id` 而不是可调用 API 的真实 `file_token`;其余模式仍返回真实 `file_token`。
|
||||
|
||||
## 性能注意
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
| `fail` | 默认。返回所有冲突条目的完整信息,不写远端 |
|
||||
| `newest` | 只把本地文件与 `modified_time` 最新的远端文件对齐 |
|
||||
| `oldest` | 只把本地文件与 `created_time` 最早的远端文件对齐 |
|
||||
|
||||
`+push` 不提供 `rename`:本地一个文件无法表达要覆盖多个远端对象。若用户想保留多个云端副本,应先显式整理云端文件,再重新 push。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -32,6 +44,10 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite
|
||||
|
||||
# 云端已有多个同名二进制文件时,显式选择一个远端目标再覆盖
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite --on-duplicate-remote newest
|
||||
|
||||
# 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件
|
||||
# (--delete-remote 必须搭配 --yes,否则会被 Validate 直接拒绝;
|
||||
# 且 Validate 阶段会动态检查 space:document:delete scope,缺权限会立刻失败,
|
||||
@@ -47,6 +63,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") |
|
||||
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `newest` / `oldest` |
|
||||
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
|
||||
@@ -55,6 +72,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。
|
||||
- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token,不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。
|
||||
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
@@ -71,6 +89,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
- **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token`、`type`、名称、大小和时间字段;其中 `created_time`、`modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -38,6 +42,8 @@ lark-cli drive +status \
|
||||
|
||||
## 输出 schema
|
||||
|
||||
成功时:
|
||||
|
||||
```json
|
||||
{
|
||||
"new_local": [{"rel_path": "..."}],
|
||||
@@ -49,10 +55,34 @@ lark-cli drive +status \
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||
|
||||
远端同名文件冲突时:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "duplicate_remote_path",
|
||||
"message": "multiple Drive entries map to the same rel_path",
|
||||
"detail": {
|
||||
"duplicates_remote": [
|
||||
{
|
||||
"rel_path": "dup.txt",
|
||||
"entries": [
|
||||
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
|
||||
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 比较范围
|
||||
|
||||
- **只比对 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)都被跳过 —— 它们没有等价的本地二进制可对齐,否则会在 `new_remote` 里产生大量误报。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
||||
|
||||
## 范围限制
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -18,6 +18,7 @@ metadata:
|
||||
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
|
||||
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
|
||||
- **Reaction**: An emoji reaction on a message.
|
||||
- **Flag**: A bookmark on a message or thread.
|
||||
|
||||
## Resource Relationships
|
||||
|
||||
@@ -50,6 +51,17 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
### Flag Types
|
||||
|
||||
Flags support two layers:
|
||||
|
||||
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
|
||||
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
|
||||
|
||||
Item types for feed-layer flags:
|
||||
- **ItemTypeThread** (4) = thread in a topic-style chat
|
||||
- **ItemTypeMsgThread** (11) = thread in a regular chat
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -58,7 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
@@ -66,6 +78,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type |
|
||||
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -86,7 +101,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
|
||||
### chat.members
|
||||
|
||||
- `bots` — 获取群内机器人列表。 Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
|
||||
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
@@ -94,7 +109,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
### messages
|
||||
|
||||
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
|
||||
- `forward` — 转发消息。Identity: `bot` only (`tenant_access_token`).
|
||||
- `forward` — 转发消息。Identity: supports `user` and `bot`.
|
||||
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
|
||||
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
|
||||
|
||||
@@ -105,6 +120,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
- `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
|
||||
- `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
|
||||
|
||||
### threads
|
||||
|
||||
- `forward` — 转发话题。Identity: supports `user` and `bot`.
|
||||
|
||||
### images
|
||||
|
||||
- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
|
||||
@@ -132,6 +151,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `messages.forward` | `im:message` |
|
||||
| `messages.merge_forward` | `im:message` |
|
||||
| `messages.read_users` | `im:message:readonly` |
|
||||
| `threads.forward` | `im:message` |
|
||||
| `reactions.batch_query` | `im:message.reactions:read` |
|
||||
| `reactions.create` | `im:message.reactions:write_only` |
|
||||
| `reactions.delete` | `im:message.reactions:write_only` |
|
||||
@@ -140,3 +160,4 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `pins.create` | `im:message.pins:write_only` |
|
||||
| `pins.delete` | `im:message.pins:write_only` |
|
||||
| `pins.list` | `im:message.pins:read` |
|
||||
|
||||
|
||||
67
skills/lark-im/references/lark-im-flag-cancel.md
Normal file
67
skills/lark-im/references/lark-im-flag-cancel.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# im +flag-cancel
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
|
||||
|
||||
This skill maps to shortcut: `lark-cli im +flag-cancel`. Underlying API: `POST /open-apis/im/v1/flags/cancel`.
|
||||
|
||||
## Double-Cancel Behavior (Important)
|
||||
|
||||
A message can have flags on both layers simultaneously:
|
||||
- Message layer: `(default, message)`
|
||||
- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type
|
||||
|
||||
**When no `--flag-type` is specified, the shortcut performs double-cancel**: removes both message layer and feed layer flags. The server handles cancel requests for non-existent flags idempotently, so this is safe.
|
||||
|
||||
**Feed layer item_type is determined by chat_mode**:
|
||||
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
|
||||
- Regular chat (`chat_mode=group`) → `item_type=msg_thread`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Double-cancel both layers (recommended default)
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx
|
||||
|
||||
# Only cancel message layer
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx --flag-type message
|
||||
|
||||
# Only cancel feed layer (need to specify item-type)
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx --item-type thread --flag-type feed
|
||||
|
||||
# Preview request
|
||||
lark-cli im +flag-cancel --as user --message-id om_xxx --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--message-id <om_xxx>` | Required | Message ID |
|
||||
| `--flag-type <name>` | No | `message` or `feed`; **when omitted, double-cancels both layers** |
|
||||
| `--item-type <name>` | No | `default\|thread\|msg_thread`; required when `--flag-type feed` |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
## Idempotency
|
||||
|
||||
The server doesn't return an error for cancel requests when the flag doesn't exist, so repeated `+cancel` calls are idempotent.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read`
|
||||
- The message/chat read scopes are used by the default double-cancel path to auto-detect the feed-layer item type.
|
||||
|
||||
## Note
|
||||
|
||||
- **Do not call +flag-list for verification**: If the cancel API returns success, the flag is removed. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary.
|
||||
|
||||
## Finding Message ID Efficiently
|
||||
|
||||
If you have message content but not the message ID:
|
||||
|
||||
1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result
|
||||
2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient
|
||||
|
||||
```bash
|
||||
# Search by message content to find message_id
|
||||
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
|
||||
```
|
||||
67
skills/lark-im/references/lark-im-flag-create.md
Normal file
67
skills/lark-im/references/lark-im-flag-create.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# im +flag-create
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
|
||||
|
||||
This skill maps to shortcut: `lark-cli im +flag-create`. Underlying API: `POST /open-apis/im/v1/flags`.
|
||||
|
||||
## Default Behavior
|
||||
|
||||
- **Message-layer flag** (default): `item_type=default, flag_type=message`
|
||||
- **Feed-layer flag**: Use `--flag-type feed` — automatically detects chat type to determine `item_type`:
|
||||
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
|
||||
- Regular chat (`chat_mode=group`) → `item_type=msg_thread`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Flag a message (default: message-layer)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx
|
||||
|
||||
# Create feed-layer flag (auto-detects chat type)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx --flag-type feed
|
||||
|
||||
# Explicit item-type override (rarely needed)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx --item-type thread --flag-type feed
|
||||
|
||||
# Preview request (dry-run, doesn't send)
|
||||
lark-cli im +flag-create --as user --message-id om_xxx --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--message-id <om_xxx>` | Required | Message ID |
|
||||
| `--flag-type <name>` | No | `message` (default) or `feed` |
|
||||
| `--item-type <name>` | No | Override auto-detection: `default\|thread\|msg_thread` (rarely needed) |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
## Valid Combinations
|
||||
|
||||
The server only accepts these `(item_type, flag_type)` pairs:
|
||||
|
||||
- `(default, message)` — regular message flag
|
||||
- `(thread, feed)` — feed flag in topic-style chat
|
||||
- `(msg_thread, feed)` — feed flag in regular chat
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scopes: `im:feed.flag:write`, `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`, `im:chat:read`
|
||||
- The message/chat read scopes are used when `--flag-type feed` is used without explicit `--item-type` so the CLI can auto-detect chat type.
|
||||
- If missing, CLI will prompt with `lark-cli auth login --scope "..."`
|
||||
|
||||
## Note
|
||||
|
||||
- **Do not call +flag-list for verification**: If the create API returns success, the flag is created. Calling +flag-list to verify is expensive (requires full pagination) and unnecessary.
|
||||
|
||||
## Finding Message ID Efficiently
|
||||
|
||||
If you have message content but not the message ID:
|
||||
|
||||
1. **Use `+messages-search`** to find the message by content, then extract `message_id` from the result
|
||||
2. **Do NOT use `+flag-list`** to find the message — it requires full pagination and is very inefficient
|
||||
|
||||
```bash
|
||||
# Search by message content to find message_id
|
||||
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
|
||||
```
|
||||
100
skills/lark-im/references/lark-im-flag-list.md
Normal file
100
skills/lark-im/references/lark-im-flag-list.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# im +flag-list
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
|
||||
|
||||
This skill maps to shortcut: `lark-cli im +flag-list`. Underlying API: `GET /open-apis/im/v1/flags`.
|
||||
|
||||
## Sorting Rules (Important)
|
||||
|
||||
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, you cannot simply take the first page's items as the latest flags — you must paginate through all pages and take the last item on the last page as the newest.
|
||||
|
||||
Recommended: use `--page-all` for auto-pagination to get the complete list, then use `-q '.data.flag_items[-1]'` to get the latest item.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Fetch first page (default page-size=50)
|
||||
lark-cli im +flag-list --as user
|
||||
|
||||
# Manual pagination with custom page size
|
||||
lark-cli im +flag-list --as user --page-size 30 --page-token <page_token>
|
||||
|
||||
# Auto-paginate to get all flags (recommended)
|
||||
lark-cli im +flag-list --as user --page-all
|
||||
|
||||
# Auto-paginate + get the latest flag
|
||||
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'
|
||||
|
||||
# Auto-paginate + get only item_id list
|
||||
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id'
|
||||
|
||||
# Disable auto-enrichment of message content (enabled by default)
|
||||
lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false
|
||||
|
||||
# Limit max pages (default 20, max 1000)
|
||||
lark-cli im +flag-list --as user --page-all --page-limit 10
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|------|------|------|
|
||||
| `--page-size <n>` | 50 | Range 1-50 (server max is 50) |
|
||||
| `--page-token <token>` | empty | Pagination token from previous page; empty string must still be provided |
|
||||
| `--page-all` | false | Auto-paginate to fetch all pages and merge results |
|
||||
| `--page-limit <n>` | 20 | Max pages in `--page-all` mode (max 1000) |
|
||||
| `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
## Response Structure
|
||||
|
||||
The response has `data` as the main body, with fields described below:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|------|
|
||||
| `flag_items` | array | List of currently existing (not canceled) flags, sorted by `update_time` ascending |
|
||||
| `delete_flag_items` | array | List of previously canceled flags, sorted by `update_time` ascending |
|
||||
| `messages` | array | Message content inlined by the server for `(default, message)` type flags |
|
||||
| `has_more` | boolean | Whether there's a next page |
|
||||
| `page_token` | string | Pagination token for the next page |
|
||||
|
||||
Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched via `mget` by the shortcut, and written to the corresponding entry's `message` field.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids <item_id>`.
|
||||
|
||||
## Response Example (Sanitized)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"delete_flag_items": [
|
||||
{
|
||||
"create_time": "xxx",
|
||||
"flag_type": "xxx",
|
||||
"item_id": "xxx",
|
||||
"item_type": "xxx",
|
||||
"update_time": "xxx"
|
||||
}
|
||||
],
|
||||
"flag_items": [
|
||||
{
|
||||
"create_time": "xxx",
|
||||
"flag_type": "xxx",
|
||||
"item_id": "xxx",
|
||||
"item_type": "xxx",
|
||||
"update_time": "xxx"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"messages": [],
|
||||
"page_token": "xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
- Base scope: `im:feed.flag:read`
|
||||
- Additional scopes only when `--enrich-feed-thread=true` needs to fetch missing message content: `im:message.group_msg:get_as_user`, `im:message.p2p_msg:get_as_user`
|
||||
@@ -100,7 +100,6 @@ func TestCalendar_CreateEvent(t *testing.T) {
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -52,7 +52,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
"summary": calendarSummary,
|
||||
"description": calendarDescription,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -116,7 +115,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
Data: map[string]any{
|
||||
"summary": updatedCalendarSummary,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 6.9%
|
||||
- Covered: 7
|
||||
- Coverage: 24.1%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
|
||||
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
- Blocked area: live upload, live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`, but there is still no live upload workflow coverage.
|
||||
- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`; live duplicate/status workflows also use real `+upload` to seed remote fixtures.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -26,9 +29,11 @@
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflow seeds via `+upload` and asserts all four buckets |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
| ✕ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget (dry-run only) | `--wiki-token`; `parent_type=wiki`; `parent_node` | no live upload workflow yet |
|
||||
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders | dry-run covers wiki-target shape; live workflows assert returned file tokens and consume the uploaded fixtures |
|
||||
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |
|
||||
|
||||
208
tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go
Normal file
208
tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string {
|
||||
t.Helper()
|
||||
stagePath := filepath.Join(workDir, stageName)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write stage file %s: %v", stageName, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(stagePath) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stageName,
|
||||
"--folder-token", folderToken,
|
||||
"--name", remoteName,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
return fileToken
|
||||
}
|
||||
|
||||
t.Run("status and pull handle duplicate remote files", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first")
|
||||
secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second")
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if statusResult.ExitCode == 0 {
|
||||
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) {
|
||||
t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
|
||||
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if pullFailResult.ExitCode == 0 {
|
||||
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
|
||||
}
|
||||
if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) {
|
||||
t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)
|
||||
}
|
||||
|
||||
pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--on-duplicate-remote", "rename",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pullRenameResult.AssertExitCode(t, 0)
|
||||
pullRenameResult.AssertStdoutStatus(t, true)
|
||||
|
||||
items := gjson.Get(pullRenameResult.Stdout, "data.items")
|
||||
if items.Array() == nil || len(items.Array()) != 2 {
|
||||
t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 {
|
||||
t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout)
|
||||
}
|
||||
relPaths := []string{
|
||||
gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(),
|
||||
gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(),
|
||||
}
|
||||
var renamedRel string
|
||||
for _, rel := range relPaths {
|
||||
if rel != "dup.txt" {
|
||||
renamedRel = rel
|
||||
}
|
||||
}
|
||||
if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") {
|
||||
t.Fatalf("renamed rel_path = %q, want dup__lark_<hash>.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout)
|
||||
}
|
||||
if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) &&
|
||||
!strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) {
|
||||
t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) {
|
||||
t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
require.FileExists(t, filepath.Join(workDir, "local", "dup.txt"))
|
||||
require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel)))
|
||||
})
|
||||
|
||||
t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil {
|
||||
t.Fatalf("write local dup.txt: %v", err)
|
||||
}
|
||||
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first")
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
|
||||
|
||||
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pushResult.AssertExitCode(t, 0)
|
||||
pushResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
|
||||
t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 {
|
||||
t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
|
||||
t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" {
|
||||
t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
|
||||
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -171,3 +171,44 @@ func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"rename", "newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,3 +241,44 @@ func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# IM CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 9
|
||||
- Coverage: 31.0%
|
||||
- Denominator: 30 leaf commands
|
||||
- Covered: 11
|
||||
- Coverage: 36.7%
|
||||
|
||||
## Summary
|
||||
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
|
||||
@@ -12,6 +12,7 @@
|
||||
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
|
||||
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
|
||||
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
|
||||
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
|
||||
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
|
||||
|
||||
## Command Table
|
||||
@@ -37,9 +38,10 @@
|
||||
| ✕ | im chats update | api | | none | only covered indirectly through `+chat-update` |
|
||||
| ✕ | im images create | api | | none | no image upload workflow yet |
|
||||
| ✕ | im messages delete | api | | none | no recall workflow yet |
|
||||
| ✕ | im messages forward | api | | none | no forward workflow yet |
|
||||
| ✓ | im messages forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward message with api command as user | `message_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh message back into the test chat using UAT |
|
||||
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
|
||||
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
|
||||
| ✓ | im threads forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward thread with api command as user | `thread_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh thread back into the test chat using UAT |
|
||||
| ✕ | im pins create | api | | none | pin workflows not covered |
|
||||
| ✕ | im pins delete | api | | none | pin workflows not covered |
|
||||
| ✕ | im pins list | api | | none | pin workflows not covered |
|
||||
|
||||
304
tests/cli_e2e/im/flag_workflow_test.go
Normal file
304
tests/cli_e2e/im/flag_workflow_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestIM_FlagWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "im-flag-" + suffix
|
||||
messageText := "flag-test-msg-" + suffix
|
||||
var chatID string
|
||||
var messageID string
|
||||
|
||||
t.Run("create chat as user", func(t *testing.T) {
|
||||
chatID = createChatAs(t, parentT, ctx, chatName, "user")
|
||||
})
|
||||
|
||||
t.Run("send message as user", func(t *testing.T) {
|
||||
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
|
||||
})
|
||||
|
||||
t.Run("create flag as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-create",
|
||||
"--message-id", messageID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("list flags as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "10",
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
// Check if our message is in the list
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify our flagged message is in the list
|
||||
var found bool
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
found = true
|
||||
// Verify it's a message-type flag (flag_type=2)
|
||||
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected message %s in flag list", messageID)
|
||||
})
|
||||
|
||||
t.Run("cancel flag as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-cancel",
|
||||
"--message-id", messageID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("verify flag removed", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "10",
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
// Check if our message is still in the list
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
return true // Still there, retry
|
||||
}
|
||||
}
|
||||
return false // Not found, success
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify our message is NOT in the list
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
require.NotEqual(t, messageID, item.Get("item_id").String(), "message should not be in flag list after cancel")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIM_FlagCreateWithExplicitTypeAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "im-flag-explicit-" + suffix
|
||||
messageText := "flag-explicit-msg-" + suffix
|
||||
var chatID string
|
||||
var messageID string
|
||||
|
||||
t.Run("create chat as user", func(t *testing.T) {
|
||||
chatID = createChatAs(t, parentT, ctx, chatName, "user")
|
||||
})
|
||||
|
||||
t.Run("send message as user", func(t *testing.T) {
|
||||
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
|
||||
})
|
||||
|
||||
t.Run("create flag with explicit types as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-create",
|
||||
"--message-id", messageID,
|
||||
"--item-type", "default",
|
||||
"--flag-type", "message",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("list flags to verify explicit types as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "10",
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify explicit types were applied
|
||||
var found bool
|
||||
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
|
||||
if item.Get("item_id").String() == messageID {
|
||||
found = true
|
||||
require.Equal(t, "0", item.Get("item_type").String(), "expected item_type=0 (default)")
|
||||
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected message %s in flag list", messageID)
|
||||
})
|
||||
|
||||
t.Run("cancel flag with explicit types as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-cancel",
|
||||
"--message-id", messageID,
|
||||
"--flag-type", "message",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIM_FlagListPaginationAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
t.Run("list flags with page-all as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--page-size", "5",
|
||||
"--page-all",
|
||||
"--page-limit", "3",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIM_FlagDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("create flag dry-run", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-create",
|
||||
"--message-id", "om_test_dry_run",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "POST")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
|
||||
require.Contains(t, result.Stdout, "flag_items")
|
||||
require.Contains(t, result.Stdout, "om_test_dry_run")
|
||||
})
|
||||
|
||||
t.Run("cancel flag dry-run with om", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-cancel",
|
||||
"--message-id", "om_test_dry_run",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "POST")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags/cancel")
|
||||
require.Contains(t, result.Stdout, "flag_items")
|
||||
require.Contains(t, result.Stdout, "om_test_dry_run")
|
||||
})
|
||||
|
||||
t.Run("list flag dry-run", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+flag-list",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Stdout, "GET")
|
||||
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
|
||||
require.Contains(t, result.Stdout, "page_size")
|
||||
})
|
||||
}
|
||||
184
tests/cli_e2e/im/message_forward_workflow_test.go
Normal file
184
tests/cli_e2e/im/message_forward_workflow_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
messageText := "im-forward-msg-" + suffix
|
||||
replyText := "im-forward-reply-" + suffix
|
||||
|
||||
selfOpenID := getSelfOpenID(t, ctx)
|
||||
chatID, messageID := sendDirectMessageToUser(t, ctx, selfOpenID, messageText, "bot")
|
||||
|
||||
t.Run("forward message with api command as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "messages", "forward"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"message_id": messageID,
|
||||
"receive_id_type": "chat_id",
|
||||
"uuid": "msg-forward-" + suffix,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"receive_id": chatID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipIfMissingIMForwardPermission(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
|
||||
require.NotEqual(t, messageID, forwardedID, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
var threadID string
|
||||
t.Run("create thread fixture as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", messageID,
|
||||
"--text", replyText,
|
||||
"--reply-in-thread",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
threadID = findThreadIDForMessage(t, ctx, chatID, messageID, "bot")
|
||||
})
|
||||
|
||||
t.Run("forward thread with api command as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "threads", "forward"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"thread_id": threadID,
|
||||
"receive_id_type": "chat_id",
|
||||
"uuid": "thread-forward-" + suffix,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"receive_id": chatID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipIfMissingIMForwardPermission(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "merge_forward", gjson.Get(result.Stdout, "data.msg_type").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
|
||||
func findThreadIDForMessage(t *testing.T, ctx context.Context, chatID string, messageID string, defaultAs string) string {
|
||||
t.Helper()
|
||||
|
||||
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"im", "+chat-messages-list",
|
||||
"--chat-id", chatID,
|
||||
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
|
||||
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
|
||||
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
|
||||
if item.Get("message_id").String() == messageID {
|
||||
threadID := item.Get("thread_id").String()
|
||||
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
|
||||
return threadID
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected message %s in stdout:\n%s", messageID, listResult.Stdout)
|
||||
return ""
|
||||
}
|
||||
|
||||
func getSelfOpenID(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"contact", "+get-user"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
|
||||
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
|
||||
return openID
|
||||
}
|
||||
|
||||
func sendDirectMessageToUser(t *testing.T, ctx context.Context, userOpenID string, text string, defaultAs string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-send",
|
||||
"--user-id", userOpenID,
|
||||
"--text", text,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
|
||||
messageID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, chatID, "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, messageID, "stdout:\n%s", result.Stdout)
|
||||
return chatID, messageID
|
||||
}
|
||||
|
||||
func skipIfMissingIMForwardPermission(t *testing.T, result *clie2e.Result) {
|
||||
t.Helper()
|
||||
if result == nil || result.ExitCode == 0 {
|
||||
return
|
||||
}
|
||||
stderrLower := strings.ToLower(result.Stderr)
|
||||
if strings.Contains(stderrLower, "permission denied") ||
|
||||
strings.Contains(stderrLower, "230027") ||
|
||||
strings.Contains(stderrLower, "missing_scope") {
|
||||
t.Skipf("skip UAT forward workflow due to missing IM forward permissions: %s", result.Stderr)
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,6 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
|
||||
"title": "lark-cli-e2e-sheets-resource-" + suffix,
|
||||
"folder_token": folderToken,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -94,7 +94,6 @@ func TestSheets_FilterWorkflow(t *testing.T) {
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
Reference in New Issue
Block a user