mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 10:29:48 +08:00
Compare commits
5 Commits
feat/remot
...
v1.0.63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
462358a746 | ||
|
|
ad4d3cb874 | ||
|
|
171778951d | ||
|
|
a6797ac2e4 | ||
|
|
d852ab311b |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add meeting message send shortcut (#1643)
|
||||
- **doc**: Add document word statistics helper (#1697)
|
||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||
- **base**: Support JSON array input for field create (#1661)
|
||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||
- **cli**: Reduce public content credential false positives (#1700)
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
@@ -1317,6 +1333,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
|
||||
@@ -10,8 +10,15 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -102,6 +102,35 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.61",
|
||||
"version": "1.0.63",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -265,9 +265,10 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
throw new Error(
|
||||
"[SECURITY] checksums.txt not found; refusing to install an unverified binary."
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -285,11 +286,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error(
|
||||
"[SECURITY] missing expected checksum; refusing to install an unverified binary."
|
||||
);
|
||||
}
|
||||
if (expectedHash === null) return;
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,17 +52,11 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] when checksums.txt does not exist (fail-closed)", () => {
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /checksums\.txt not found/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -131,19 +125,6 @@ describe("verifyChecksum", () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("verifyChecksum throws [SECURITY] on null/empty expectedHash (fail-closed)", () => {
|
||||
const filePath = makeTmpFile("content");
|
||||
for (const expectedHash of [null, ""]) {
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertAllowedHost", () => {
|
||||
|
||||
@@ -37,11 +37,16 @@ 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"`
|
||||
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"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type drivePullTarget struct {
|
||||
@@ -189,6 +194,9 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
downloadToken := targetFile.DownloadToken
|
||||
itemFileToken := targetFile.ItemFileToken
|
||||
@@ -204,13 +212,9 @@ var DrivePull = common.Shortcut{
|
||||
// pre-existing file under --if-exists=skip silently
|
||||
// hides the conflict. Surface as a failure.
|
||||
if info.IsDir() {
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: itemFileToken,
|
||||
SourceID: itemSourceID,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
conflictErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "local path is a directory, remote is a regular file: %s", target)
|
||||
item, _ := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "local", conflictErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
@@ -223,9 +227,14 @@ var DrivePull = common.Shortcut{
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
|
||||
item, terminal := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "download", err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
|
||||
@@ -251,7 +260,8 @@ var DrivePull = common.Shortcut{
|
||||
for _, absPath := range localAbsPaths {
|
||||
rel, relErr := filepath.Rel(safeRoot, absPath)
|
||||
if relErr != nil {
|
||||
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
|
||||
item, _ := drivePullFailedItem(absPath, "", "", "delete_failed", "delete_local", relErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -271,7 +281,9 @@ var DrivePull = common.Shortcut{
|
||||
// acceptable here. Shortcuts cannot import internal/vfs
|
||||
// directly (depguard rule shortcuts-no-vfs).
|
||||
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
|
||||
deleteErr := errs.NewInternalError(errs.SubtypeFileIO, "delete local %q: %s", rel, err).WithCause(err)
|
||||
item, _ := drivePullFailedItem(rel, "", "", "delete_failed", "delete_local", deleteErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -286,6 +298,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -317,6 +330,32 @@ var DrivePull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err error) (drivePullItem, bool) {
|
||||
decision := driveClassifyBatchFailure(err)
|
||||
item := drivePullItem{
|
||||
RelPath: relPath,
|
||||
FileToken: fileToken,
|
||||
SourceID: sourceID,
|
||||
Action: action,
|
||||
Error: err.Error(),
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
Subtype: decision.Subtype,
|
||||
Retryable: driveBoolPtr(decision.Retryable),
|
||||
}
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -1032,6 +1032,66 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullAbortsAfterDownloadForbidden(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("forbidden"),
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "download" || item["error_class"] != "permission_denied" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(http.StatusForbidden) || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure classification: %#v", item)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
|
||||
// regression for the "link/.." escape applied to --delete-local — the
|
||||
// most dangerous variant, since the bug would otherwise let the kernel
|
||||
|
||||
@@ -29,12 +29,25 @@ const (
|
||||
)
|
||||
|
||||
type drivePushItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type driveBatchFailureDecision struct {
|
||||
Class string
|
||||
Code int
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -248,9 +261,14 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
|
||||
item, terminal := drivePushFailedItem(relDir, "", "failed", "create_folder", 0, ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
|
||||
@@ -266,6 +284,9 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
|
||||
@@ -275,9 +296,14 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
|
||||
if parentErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()})
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "failed", "create_folder", localFile.Size, parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
|
||||
@@ -301,9 +327,14 @@ var DrivePush = common.Shortcut{
|
||||
if failedToken == "" {
|
||||
failedToken = entry.FileToken
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
item, terminal := drivePushFailedItem(rel, failedToken, "failed", "upload", localFile.Size, upErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
|
||||
@@ -314,16 +345,26 @@ var DrivePush = common.Shortcut{
|
||||
parentRel := drivePushParentRel(rel)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
|
||||
item, terminal := drivePushFailedItem(rel, "", "failed", "create_folder", localFile.Size, ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
item, terminal := drivePushFailedItem(rel, "", "failed", "upload", localFile.Size, upErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
|
||||
@@ -350,7 +391,11 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
abortDelete := false
|
||||
for _, rel := range remoteRelPaths {
|
||||
if abortDelete {
|
||||
break
|
||||
}
|
||||
keepToken := ""
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
if chosen, ok := remoteFiles[rel]; ok {
|
||||
@@ -362,8 +407,14 @@ var DrivePush = common.Shortcut{
|
||||
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()})
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
@@ -378,6 +429,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -507,6 +559,91 @@ func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemo
|
||||
return cmp >= 0
|
||||
}
|
||||
|
||||
func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int64, err error) (drivePushItem, bool) {
|
||||
decision := driveClassifyBatchFailure(err)
|
||||
item := drivePushItem{
|
||||
RelPath: relPath,
|
||||
FileToken: fileToken,
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
Subtype: decision.Subtype,
|
||||
Retryable: driveBoolPtr(decision.Retryable),
|
||||
}
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveBoolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision := driveBatchFailureDecision{Class: "unknown", Retryable: errs.IsRetryable(err)}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return decision
|
||||
}
|
||||
decision.Code = problem.Code
|
||||
decision.Subtype = string(problem.Subtype)
|
||||
decision.Retryable = problem.Retryable
|
||||
|
||||
switch {
|
||||
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991672:
|
||||
decision.Class = "app_scope_missing"
|
||||
decision.Terminal = true
|
||||
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991679:
|
||||
decision.Class = "user_scope_missing"
|
||||
decision.Terminal = true
|
||||
case problem.Category == errs.CategoryAuthorization && problem.Subtype == errs.SubtypePermissionDenied:
|
||||
decision.Class = "permission_denied"
|
||||
decision.Terminal = true
|
||||
case problem.Category == errs.CategoryNetwork && problem.Code == http.StatusForbidden:
|
||||
decision.Class = "permission_denied"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeInvalidParameters || problem.Code == 1061002:
|
||||
decision.Class = "invalid_api_parameters"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
||||
decision.Class = "rate_limited"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
decision.Class = "server_error"
|
||||
decision.Terminal = true
|
||||
case problem.Subtype == errs.SubtypeFailedPrecondition:
|
||||
decision.Class = "local_file_changed"
|
||||
default:
|
||||
decision.Class = string(problem.Subtype)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
@@ -600,6 +737,12 @@ func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeCont
|
||||
// the three-step prepare/part/finish flow, which mirrors drive +upload's
|
||||
// existing multipart logic.
|
||||
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
if err := drivePushValidateUploadRequest(file, existingToken, parentToken); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := drivePushVerifyLocalSnapshot(runtime, file); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
|
||||
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
|
||||
// Multipart finish does not return version on the existing
|
||||
@@ -612,6 +755,44 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
|
||||
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
|
||||
}
|
||||
|
||||
func drivePushValidateUploadRequest(file drivePushLocalFile, existingToken, parentToken string) error {
|
||||
if strings.TrimSpace(file.FileName) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file name is empty", file.RelPath)
|
||||
}
|
||||
if file.Size < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file size is negative", file.RelPath)
|
||||
}
|
||||
if strings.TrimSpace(parentToken) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: parent folder token is empty", file.RelPath)
|
||||
}
|
||||
if err := validate.ResourceName(parentToken, "parent_node"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: %s", file.RelPath, err)
|
||||
}
|
||||
if existingToken != "" {
|
||||
if err := validate.ResourceName(existingToken, "file_token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot overwrite %q: %s", file.RelPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePushVerifyLocalSnapshot(runtime *common.RuntimeContext, file drivePushLocalFile) error {
|
||||
info, err := runtime.FileIO().Stat(file.OpenPath)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer readable: %v", file.RelPath, err).WithCause(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer a regular file", file.RelPath)
|
||||
}
|
||||
if info.Size() != file.Size {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot size no longer matches", file.RelPath)
|
||||
}
|
||||
if modTimer, ok := info.(interface{ ModTime() time.Time }); ok && !modTimer.ModTime().Equal(file.ModTime) {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot modtime no longer matches", file.RelPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
f, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,8 +5,10 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,12 +16,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// countingOpenProvider wraps a fileio.Provider and counts FileIO.Open
|
||||
@@ -652,6 +656,82 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_a",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061004,
|
||||
"msg": "forbidden",
|
||||
},
|
||||
})
|
||||
// No DELETE stub for tok_b: terminal delete failure must stop before it.
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %T %v", err, err)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "delete_failed" || item["phase"] != "delete" || item["error_class"] != "permission_denied" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061004) || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["file_token"] == "tok_b" {
|
||||
t.Fatalf("terminal delete failure must abort before tok_b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -886,21 +966,22 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
|
||||
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// summary.failed should reflect the missing version; summary.uploaded
|
||||
// should not pretend the overwrite succeeded.
|
||||
if !strings.Contains(out, `"failed": 1`) {
|
||||
t.Errorf("expected failed=1, got: %s", out)
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if !strings.Contains(out, "no version") {
|
||||
t.Errorf("expected error about missing version in items[].error, got: %s", out)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
if got, _ := items[0]["error"].(string); !strings.Contains(got, "no version") {
|
||||
t.Errorf("items[0].error = %q, want missing-version message", got)
|
||||
}
|
||||
// Pin the token-stability contract: the failed item must surface the
|
||||
// token returned by upload_all (tok_keep_new), NOT the fallback
|
||||
// entry.FileToken (tok_keep). Without this, a regression that always
|
||||
// uses entry.FileToken on failure would slip through.
|
||||
if !strings.Contains(out, `"file_token": "tok_keep_new"`) {
|
||||
t.Errorf("expected failed item to surface upload_all's returned file_token (tok_keep_new), got: %s", out)
|
||||
if got := items[0]["file_token"]; got != "tok_keep_new" {
|
||||
t.Errorf("items[0].file_token = %v, want tok_keep_new", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,24 +1043,313 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
|
||||
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Partial failure reports an ok:false result envelope on stdout (not a
|
||||
// misleading ok:true) while still carrying BOTH the succeeded and failed
|
||||
// items — consistent with the pre-change payload. The failed side is
|
||||
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
|
||||
envelope := decodeDrivePushStdout(t, stdout.Bytes())
|
||||
if envelope.OK {
|
||||
t.Fatalf("partial failure must emit ok=false; stdout=%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(out, `"failed": 1`) {
|
||||
t.Errorf("expected failed=1, got: %s", out)
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
// The freshly returned token must be the one in items[].file_token,
|
||||
// not the stale entry.FileToken (tok_keep_old).
|
||||
if !strings.Contains(out, `"file_token": "tok_keep_partial"`) {
|
||||
t.Errorf("expected items[].file_token to surface upload_all's returned token (tok_keep_partial), got: %s", out)
|
||||
if got := items[0]["file_token"]; got != "tok_keep_partial" {
|
||||
t.Errorf("items[0].file_token = %v, want tok_keep_partial", got)
|
||||
}
|
||||
if strings.Contains(out, `"file_token": "tok_keep_old"`) {
|
||||
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; got: %s", out)
|
||||
if got := items[0]["file_token"]; got == "tok_keep_old" {
|
||||
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; item=%#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061002,
|
||||
"msg": "params error.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "invalid_api_parameters" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061002) || item["subtype"] != "invalid_parameters" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("terminal upload params error must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll a: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "Access denied. One of the following scopes is required: [drive:drive, space:folder:create].",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "app_scope_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(99991672) || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b" {
|
||||
t.Fatalf("missing folder-create scope must abort before b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "changing.txt")
|
||||
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if err := os.WriteFile(localPath, []byte("after-change"), 0o644); err != nil {
|
||||
t.Fatalf("mutate local file: %v", err)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != false {
|
||||
t.Fatalf("summary.aborted = %v, want false", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["error"].(string); !strings.Contains(got, "local file changed during push") {
|
||||
t.Fatalf("items[0].error = %q, want local-change message", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "httpmock: no stub") {
|
||||
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{
|
||||
RelPath: "missing.txt",
|
||||
OpenPath: filepath.Join("local", "missing.txt"),
|
||||
FileName: "missing.txt",
|
||||
Size: 1,
|
||||
ModTime: time.Now(),
|
||||
})
|
||||
problem, ok := errs.ProblemOf(problemErr)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(snapshot error) ok=false, err=%T %v", problemErr, problemErr)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("snapshot error subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("snapshot error category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if errors.Unwrap(problemErr) == nil {
|
||||
t.Fatalf("snapshot error cause was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsSameSizeLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "changing.txt")
|
||||
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
originalModTime := time.Unix(100, 0)
|
||||
changedModTime := time.Unix(200, 0)
|
||||
if err := os.Chtimes(localPath, originalModTime, originalModTime); err != nil {
|
||||
t.Fatalf("Chtimes original: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if err := os.WriteFile(localPath, []byte("AFTER!"), 0o644); err != nil {
|
||||
t.Fatalf("mutate local file: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(localPath, changedModTime, changedModTime); err != nil {
|
||||
t.Fatalf("Chtimes changed: %v", err)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "changing.txt" || item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["error"].(string); !strings.Contains(got, "snapshot modtime no longer matches") {
|
||||
t.Fatalf("items[0].error = %q, want modtime mismatch", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "httpmock: no stub") {
|
||||
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,6 +1483,32 @@ func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type drivePushStdoutEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeDrivePushStdout(t *testing.T, stdout []byte) drivePushStdoutEnvelope {
|
||||
t.Helper()
|
||||
var envelope drivePushStdoutEnvelope
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func splitDrivePushStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
envelope := decodeDrivePushStdout(t, stdout)
|
||||
if envelope.Data.Summary == nil {
|
||||
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
||||
}
|
||||
return envelope.Data.Summary, envelope.Data.Items
|
||||
}
|
||||
|
||||
// TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the
|
||||
// behavior when a local regular file shares its rel_path with a Lark
|
||||
// native cloud document on Drive (sheet/docx/bitable/...).
|
||||
|
||||
@@ -25,12 +25,21 @@ const (
|
||||
driveSyncOnConflictAsk = "ask"
|
||||
)
|
||||
|
||||
func driveSyncActionScopes() []string {
|
||||
return []string{"drive:file:download", "drive:file:upload", "space:folder:create"}
|
||||
}
|
||||
|
||||
type driveSyncItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Direction string `json:"direction,omitempty"` // "pull" or "push"
|
||||
Error string `json:"error,omitempty"`
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Direction string `json:"direction,omitempty"` // "pull" or "push"
|
||||
Error string `json:"error,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
Retryable *bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
// DriveSync performs a two-way sync between a local directory and a Drive
|
||||
@@ -66,6 +75,7 @@ var DriveSync = common.Shortcut{
|
||||
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
|
||||
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
|
||||
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
|
||||
"Actual sync execution pre-flights download, upload, and folder-create scopes before listing or walking, so missing grants fail before any partial sync can start.",
|
||||
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -110,10 +120,8 @@ var DriveSync = common.Shortcut{
|
||||
duplicateRemote = driveDuplicateRemoteFail
|
||||
}
|
||||
quick := runtime.Bool("quick")
|
||||
if !quick {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.EnsureScopes(driveSyncActionScopes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
@@ -262,18 +270,6 @@ var DriveSync = common.Shortcut{
|
||||
var pulled, pushed, skipped, failed int
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
|
||||
if len(plannedUploads) > 0 {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
folderCache := map[string]string{"": folderToken}
|
||||
for relDir, entry := range remoteFolders {
|
||||
@@ -287,20 +283,18 @@ var DriveSync = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
|
||||
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -310,6 +304,9 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
if !ok {
|
||||
// Non-file type (doc, shortcut, etc.) — skip.
|
||||
@@ -317,8 +314,13 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
@@ -327,6 +329,9 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
if !ok {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
|
||||
@@ -336,14 +341,20 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, token, "failed", "push", "upload", upErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
|
||||
@@ -352,6 +363,9 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
localFile, hasLocal := pushLocalFiles[entry.RelPath]
|
||||
if !hasLocal {
|
||||
@@ -379,8 +393,13 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
|
||||
@@ -396,7 +415,8 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -411,8 +431,13 @@ var DriveSync = common.Shortcut{
|
||||
if failedToken == "" {
|
||||
failedToken = existingToken
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, failedToken, "failed", "push", "upload", upErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
|
||||
@@ -433,7 +458,8 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
|
||||
if err != nil {
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -441,7 +467,9 @@ var DriveSync = common.Shortcut{
|
||||
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
|
||||
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
|
||||
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
|
||||
renameErr := errs.NewInternalError(errs.SubtypeFileIO, "rename local: %s", err).WithCause(err)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", renameErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
@@ -454,19 +482,30 @@ var DriveSync = common.Shortcut{
|
||||
if rollbackErr != nil {
|
||||
errMsg += "; rollback failed: " + rollbackErr.Error()
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
|
||||
notFoundErr := errs.NewAPIError(errs.SubtypeNotFound, "%s", errMsg)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "pull", "download", notFoundErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(rootRelToCwd, entry.RelPath)
|
||||
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
downloadErr := err
|
||||
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
|
||||
errMsg := err.Error()
|
||||
if rollbackErr != nil {
|
||||
errMsg += "; rollback failed: " + rollbackErr.Error()
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", downloadErr)
|
||||
if rollbackErr != nil {
|
||||
item.Error = errMsg
|
||||
}
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
|
||||
@@ -492,6 +531,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -520,6 +560,32 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
|
||||
return remoteFiles
|
||||
}
|
||||
|
||||
func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, err error) (driveSyncItem, bool) {
|
||||
decision := driveClassifyBatchFailure(err)
|
||||
item := driveSyncItem{
|
||||
RelPath: relPath,
|
||||
FileToken: fileToken,
|
||||
Action: action,
|
||||
Direction: direction,
|
||||
Error: err.Error(),
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
Subtype: decision.Subtype,
|
||||
Retryable: driveBoolPtr(decision.Retryable),
|
||||
}
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
@@ -558,51 +624,6 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
|
||||
}
|
||||
}
|
||||
|
||||
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
|
||||
if len(newRemote) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, entry := range modified {
|
||||
switch conflictResolutions[entry.RelPath] {
|
||||
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
|
||||
planned := make([]string, 0, len(newLocal)+len(modified))
|
||||
for _, entry := range newLocal {
|
||||
planned = append(planned, entry.RelPath)
|
||||
}
|
||||
for _, entry := range modified {
|
||||
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
|
||||
planned = append(planned, entry.RelPath)
|
||||
}
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
|
||||
for _, relPath := range uploadPaths {
|
||||
parentRel := drivePushParentRel(relPath)
|
||||
if parentRel == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := folderCache[parentRel]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Empty local directories also need create_folder if not already on Drive.
|
||||
for _, relDir := range localDirs {
|
||||
if _, ok := folderCache[relDir]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
|
||||
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
if info.IsDir() {
|
||||
|
||||
@@ -311,6 +311,71 @@ func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) {
|
||||
syncTestConfig := &core.CliConfig{
|
||||
AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "100"},
|
||||
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file", "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("forbidden"),
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSync, []string{
|
||||
"+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
|
||||
summary := driveSyncStdoutSummary(t, stdout.Bytes())
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item.RelPath != "a.txt" || item.Direction != "pull" || item.Phase != "download" || item.ErrorClass != "permission_denied" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item.Code != http.StatusForbidden || item.Retryable == nil || *item.Retryable {
|
||||
t.Fatalf("unexpected failure classification: %#v", item)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins
|
||||
// pushes the local version over the remote file.
|
||||
func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) {
|
||||
@@ -1552,11 +1617,11 @@ func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
|
||||
func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
|
||||
syncTestConfig := &core.CliConfig{
|
||||
AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -1568,34 +1633,6 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("remote-a"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("remote-a"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSync, []string{
|
||||
"+sync",
|
||||
"--local-dir", "local",
|
||||
@@ -1603,11 +1640,30 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
|
||||
"--on-conflict", "remote-wins",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected exact remote-wins to succeed with download-only scope, got: %v\nstdout: %s", err, stdout.String())
|
||||
if err == nil {
|
||||
t.Fatalf("expected action-scope preflight to reject download-only scope\nstdout: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") {
|
||||
t.Fatalf("should not surface missing_scope, got: %s", stdout.String())
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
|
||||
}
|
||||
for _, scope := range []string{"drive:file:upload", "space:folder:create"} {
|
||||
found := false
|
||||
for _, missing := range permErr.MissingScopes {
|
||||
if missing == scope {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("MissingScopes = %v, want %s", permErr.MissingScopes, scope)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "folder_root") {
|
||||
t.Fatalf("preflight should fail before remote listing, got stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2552,30 +2608,6 @@ func TestDriveSyncAskConflictRemoteShortForms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly verifies
|
||||
// that driveSyncNeedsDownloadScope returns false when there are no
|
||||
// new_remote entries and all modified entries resolve to local-wins.
|
||||
func TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly(t *testing.T) {
|
||||
modified := []driveStatusEntry{{RelPath: "a.txt"}, {RelPath: "b.txt"}}
|
||||
resolutions := map[string]string{"a.txt": driveSyncOnConflictLocalWins, "b.txt": driveSyncOnConflictLocalWins}
|
||||
|
||||
if driveSyncNeedsDownloadScope(nil, modified, resolutions) {
|
||||
t.Fatal("expected false when no new_remote and all conflicts are local-wins")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth verifies that
|
||||
// driveSyncNeedsDownloadScope returns true when a modified entry resolves
|
||||
// to keep-both (which requires pulling the remote version).
|
||||
func TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth(t *testing.T) {
|
||||
modified := []driveStatusEntry{{RelPath: "a.txt"}}
|
||||
resolutions := map[string]string{"a.txt": driveSyncOnConflictKeepBoth}
|
||||
|
||||
if !driveSyncNeedsDownloadScope(nil, modified, resolutions) {
|
||||
t.Fatal("expected true when a conflict resolves to keep-both")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSyncRemoteWinsReportsMissingPullView verifies that when a
|
||||
// modified file's rel_path is not in pullRemoteFiles during the
|
||||
// remote-wins branch, a failed item is reported instead of a panic.
|
||||
@@ -3083,3 +3115,19 @@ func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
|
||||
}
|
||||
return envelope.Data.Items
|
||||
}
|
||||
|
||||
func driveSyncStdoutSummary(t *testing.T, stdout []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
if envelope.Data.Summary == nil {
|
||||
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
||||
}
|
||||
return envelope.Data.Summary
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
|
||||
VCMeetingLeave,
|
||||
VCMeetingListActive,
|
||||
VCMeetingEvents,
|
||||
VCMeetingMessageSend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
|
||||
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
|
||||
161
shortcuts/vc/vc_meeting_message_send.go
Normal file
161
shortcuts/vc/vc_meeting_message_send.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
meetingMessageTypeText = "text"
|
||||
meetingMessageTypeReaction = "reaction"
|
||||
// Keep the client-side cap below the server-side content limit.
|
||||
meetingMessageMaxTextBytes = 48 * 1024
|
||||
meetingMessageMaxUUIDBytes = 128
|
||||
)
|
||||
|
||||
// VCMeetingMessageSend sends an in-meeting text message or reaction emoji.
|
||||
var VCMeetingMessageSend = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-message-send",
|
||||
Description: "Send an in-meeting text message or reaction emoji",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.message:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to send into"},
|
||||
{Name: "msg-type", Desc: "message type: text or reaction"},
|
||||
{Name: "text", Desc: "text content when --msg-type text"},
|
||||
{Name: "emoji-type", Desc: "emoji key when --msg-type reaction, for example LOVE, THUMBSUP, VC_NoSound"},
|
||||
{Name: "uuid", Desc: "optional idempotency key"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateMeetingMessagePayload(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(buildMeetingMessageSendPath()).
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "Meeting message sent.")
|
||||
if msgType := common.GetString(data, "msg_type"); msgType != "" {
|
||||
fmt.Fprintf(w, " Type: %s\n", msgType)
|
||||
} else if msgType, _ := body["msg_type"].(string); msgType != "" {
|
||||
fmt.Fprintf(w, " Type: %s\n", msgType)
|
||||
}
|
||||
if uuid := common.GetString(data, "uuid"); uuid != "" {
|
||||
fmt.Fprintf(w, " UUID: %s\n", uuid)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildMeetingMessageSendPath() string {
|
||||
return "/open-apis/vc/v1/bots/message"
|
||||
}
|
||||
|
||||
func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
msgType, err := validateMeetingMessagePayload(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
|
||||
"msg_type": msgType,
|
||||
}
|
||||
switch msgType {
|
||||
case meetingMessageTypeText:
|
||||
body["content"] = strings.TrimSpace(runtime.Str("text"))
|
||||
case meetingMessageTypeReaction:
|
||||
body["content"] = strings.TrimSpace(runtime.Str("emoji-type"))
|
||||
}
|
||||
if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" {
|
||||
body["uuid"] = uuid
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateMeetingMessagePayload(runtime *common.RuntimeContext) (string, error) {
|
||||
msgType, err := resolveMeetingMessageType(runtime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msgType == meetingMessageTypeText {
|
||||
text := strings.TrimSpace(runtime.Str("text"))
|
||||
if len(text) > meetingMessageMaxTextBytes {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--text is too long; max %d bytes", meetingMessageMaxTextBytes)).WithParam("--text")
|
||||
}
|
||||
}
|
||||
if uuid := strings.TrimSpace(runtime.Str("uuid")); len(uuid) > meetingMessageMaxUUIDBytes {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--uuid is too long; max %d bytes", meetingMessageMaxUUIDBytes)).WithParam("--uuid")
|
||||
}
|
||||
return msgType, nil
|
||||
}
|
||||
|
||||
func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) {
|
||||
msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type")))
|
||||
text := strings.TrimSpace(runtime.Str("text"))
|
||||
emojiType := strings.TrimSpace(runtime.Str("emoji-type"))
|
||||
|
||||
if msgType == "" {
|
||||
switch {
|
||||
case text != "" && emojiType == "":
|
||||
msgType = meetingMessageTypeText
|
||||
case text == "" && emojiType != "":
|
||||
msgType = meetingMessageTypeReaction
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type")
|
||||
}
|
||||
}
|
||||
|
||||
switch msgType {
|
||||
case meetingMessageTypeText:
|
||||
if text == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text")
|
||||
}
|
||||
if emojiType != "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type cannot be used when --msg-type text").WithParam("--emoji-type")
|
||||
}
|
||||
case meetingMessageTypeReaction:
|
||||
if emojiType == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type")
|
||||
}
|
||||
if text != "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text cannot be used when --msg-type reaction").WithParam("--text")
|
||||
}
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type")
|
||||
}
|
||||
return msgType, nil
|
||||
}
|
||||
312
shortcuts/vc/vc_meeting_message_send_test.go
Normal file
312
shortcuts/vc/vc_meeting_message_send_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newMeetingMessageSendRuntime() *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("meeting-id", "", "")
|
||||
cmd.Flags().String("msg-type", "", "")
|
||||
cmd.Flags().String("text", "", "")
|
||||
cmd.Flags().String("emoji-type", "", "")
|
||||
cmd.Flags().String("uuid", "", "")
|
||||
return common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
}
|
||||
|
||||
func mustSetMeetingMessageSendFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMeetingMessageSendValidationError(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendBuildBody_Text(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ")
|
||||
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
|
||||
}
|
||||
if body["msg_type"] != meetingMessageTypeText {
|
||||
t.Fatalf("msg_type = %v, want text", body["msg_type"])
|
||||
}
|
||||
if body["content"] != "hello" {
|
||||
t.Fatalf("content = %v, want hello", body["content"])
|
||||
}
|
||||
if body["uuid"] != "cid-1" {
|
||||
t.Fatalf("uuid = %v, want cid-1", body["uuid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
|
||||
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
|
||||
}
|
||||
if body["msg_type"] != meetingMessageTypeReaction {
|
||||
t.Fatalf("msg_type = %v, want reaction", body["msg_type"])
|
||||
}
|
||||
if body["content"] != "LOVE" {
|
||||
t.Fatalf("content = %v, want LOVE", body["content"])
|
||||
}
|
||||
if _, ok := body["text"]; ok {
|
||||
t.Fatalf("text should be omitted for reaction, got %#v", body["text"])
|
||||
}
|
||||
if _, ok := body["emoji_type"]; ok {
|
||||
t.Fatalf("emoji_type should be omitted for reaction, got %#v", body["emoji_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendBuildBody_ReactionVCFeedbackKey(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_NoSound")
|
||||
|
||||
body, err := buildMeetingMessageSendBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
|
||||
}
|
||||
if body["content"] != "VC_NoSound" {
|
||||
t.Fatalf("content = %v, want VC_NoSound", body["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--meeting-id")
|
||||
if !strings.Contains(err.Error(), "9-digit meeting number") {
|
||||
t.Fatalf("error = %v, want 9-digit meeting number hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsMissingEmojiType(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
|
||||
if !strings.Contains(err.Error(), "--emoji-type is required") {
|
||||
t.Fatalf("error = %v, want --emoji-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsTextMessageWithEmojiType(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "text")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
|
||||
if !strings.Contains(err.Error(), "--emoji-type cannot be used") {
|
||||
t.Fatalf("error = %v, want --emoji-type conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsReactionMessageWithText(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--text")
|
||||
if !strings.Contains(err.Error(), "--text cannot be used") {
|
||||
t.Fatalf("error = %v, want --text conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsLongText(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", strings.Repeat("a", meetingMessageMaxTextBytes+1))
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--text")
|
||||
if !strings.Contains(err.Error(), "--text is too long") {
|
||||
t.Fatalf("error = %v, want --text too long", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendValidateRejectsLongUUID(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "uuid", strings.Repeat("u", meetingMessageMaxUUIDBytes+1))
|
||||
|
||||
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
|
||||
assertMeetingMessageSendValidationError(t, err, "--uuid")
|
||||
if !strings.Contains(err.Error(), "--uuid is too long") {
|
||||
t.Fatalf("error = %v, want --uuid too long", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendDryRun_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingMessageSend, []string{
|
||||
"+meeting-message-send", "--dry-run", "--as", "user",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello",
|
||||
"--uuid", "cid-1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/vc/v1/bots/message",
|
||||
"\"meeting_id\": \"7651377260537433044\"",
|
||||
"\"msg_type\": \"text\"",
|
||||
"\"content\": \"hello\"",
|
||||
"\"uuid\": \"cid-1\"",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendDryRun_ValidationErrorEnvelope(t *testing.T) {
|
||||
runtime := newMeetingMessageSendRuntime()
|
||||
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
|
||||
|
||||
dryRun := VCMeetingMessageSend.DryRun(context.Background(), runtime)
|
||||
if got := dryRun.Format(); !strings.Contains(got, "--msg-type is required") {
|
||||
t.Fatalf("dry-run error = %v, want --msg-type required", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendExecute_Text(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: buildMeetingMessageSendPath(),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"uuid": "cid-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingMessageSend, []string{
|
||||
"+meeting-message-send", "--as", "user",
|
||||
"--format", "pretty",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello",
|
||||
"--uuid", "cid-1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
|
||||
t.Fatalf("failed to parse request body: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"meeting_id": "7651377260537433044",
|
||||
"msg_type": "text",
|
||||
"content": "hello",
|
||||
"uuid": "cid-1",
|
||||
} {
|
||||
if req[key] != want {
|
||||
t.Errorf("%s = %v, want %s", key, req[key], want)
|
||||
}
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Meeting message sent.",
|
||||
"Type: text",
|
||||
"UUID: cid-1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingMessageSendExecute_ReactionFallsBackToRequestType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: buildMeetingMessageSendPath(),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingMessageSend, []string{
|
||||
"+meeting-message-send", "--as", "user",
|
||||
"--format", "pretty",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--msg-type", "reaction",
|
||||
"--emoji-type", "LOVE",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if out := stdout.String(); !strings.Contains(out, "Type: reaction") {
|
||||
t.Fatalf("output missing fallback type: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
- 文档内容中出现嵌入的 `<sheet>`、`<bitable>` 或 `<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
|
||||
|
||||
|
||||
77
skills/lark-doc/references/lark-doc-word-stat.md
Normal file
77
skills/lark-doc/references/lark-doc-word-stat.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 文档统计:总字数 / 总字符数
|
||||
|
||||
当用户需要统计 Docx / Wiki 文档的总字数或总字符数时,使用本 skill 附带脚本 `scripts/doc_word_stat.py`。统计口径以该脚本为准,不要改用其他方式自行计算,也不要只读取 simple 摘要后统计。
|
||||
|
||||
## 调用方式
|
||||
|
||||
在线文档使用 XML full 内容,并让脚本读取 `docs +fetch --format json` 的 envelope:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
| python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty
|
||||
```
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
| python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty --fail-on-unsupported --fail-on-unknown
|
||||
```
|
||||
|
||||
## 如何读取结果
|
||||
|
||||
脚本输出 JSON。对用户汇报时默认只读两个核心字段:
|
||||
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词、数字、中文标点;英文标点不计入。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点;空格不计入。
|
||||
|
||||
其余字段用于排查或解释:
|
||||
|
||||
- `breakdown`:拆分统计来源,例如 `han_chars`、`english_words`、`digits`、`chinese_punctuations`。
|
||||
- `unknown_blocks`:脚本遇到未知 XML/Markdown 块类型;通常表示需要扩展解析规则。
|
||||
- `unsupported_blocks`:脚本识别到块类型,但当前无法可靠提取可见文本。
|
||||
- `diagnostics.has_unknown` / `diagnostics.has_unsupported`:快速判断统计是否存在覆盖风险。
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"word_count": 10,
|
||||
"char_count": 15,
|
||||
"breakdown": {
|
||||
"han_chars": 7,
|
||||
"english_words": 2,
|
||||
"number_words": 0,
|
||||
"chinese_punctuations": 1,
|
||||
"english_letters": 7,
|
||||
"digits": 0,
|
||||
"english_punctuations": 0,
|
||||
"symbol_words": 0,
|
||||
"symbol_chars": 0
|
||||
},
|
||||
"protocol": "xml",
|
||||
"unknown_blocks": [],
|
||||
"unsupported_blocks": [],
|
||||
"diagnostics": {
|
||||
"has_unknown": false,
|
||||
"has_unsupported": false,
|
||||
"types": {},
|
||||
"unknown_types": {},
|
||||
"unsupported_types": {},
|
||||
"actions": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
面向用户的回复可简化为:
|
||||
|
||||
```text
|
||||
总字数:10
|
||||
总字符数:15
|
||||
```
|
||||
1171
skills/lark-doc/scripts/doc_word_stat.py
Executable file
1171
skills/lark-doc/scripts/doc_word_stat.py
Executable file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-vc-agent
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件,如参会人加入/离开、发言、聊天、屏幕共享。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询,这些使用 lark-vc 技能。"
|
||||
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件、发送会中文本消息或会中表情。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询,这些使用 lark-vc 技能。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -26,7 +26,7 @@ metadata:
|
||||
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
|
||||
|
||||
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
|
||||
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
|
||||
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 发送会中文本或会中表情 / 机器人离会
|
||||
|
||||
按此分工路由,避免两个 skill 语义混淆。
|
||||
|
||||
@@ -35,6 +35,7 @@ metadata:
|
||||
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
|
||||
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**) | **本 skill** `+meeting-events` |
|
||||
| "我/某个用户现在在哪个会里"、"给我找当前可拉事件的 meeting_id" | **本 skill** `+meeting-list-active` |
|
||||
| "在会里发一句 xx"、"提示大家 xx"、"反馈听不到/看不到/声音清楚/效果不错"(**进行中会议**) | **本 skill** `+meeting-message-send` |
|
||||
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
|
||||
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
|
||||
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill(入会 → 读事件)→ 会议结束后用 [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md) 拉纪要 → [`lark-im`](../lark-im/SKILL.md) 发群 |
|
||||
@@ -49,7 +50,7 @@ metadata:
|
||||
| 查询目标用户且应用机器人也在会中的会议 | `--as bot --user-id <user_open_id>` | `--user-id` 必须是 `ou_...`;拿到的 `meeting_id` 后续继续用 `--as bot` 读事件 |
|
||||
| 用户明确要求应用机器人入会/旁听/代参会 | `--as bot` | 这是写操作,会真实产生入会记录;返回的 `meeting.id` 后续继续用 `--as bot` |
|
||||
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` / `+meeting-message-send` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
|
||||
|
||||
## 核心场景
|
||||
|
||||
@@ -79,14 +80,33 @@ metadata:
|
||||
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
|
||||
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings,并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`。
|
||||
|
||||
### 3. 离开会议(写操作)
|
||||
### 3. 发送会中文本或会中表情(写操作)
|
||||
|
||||
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`。
|
||||
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
|
||||
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`。
|
||||
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type`。`--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
|
||||
5. 支持普通 Feishu reaction emoji(如 `LOVE`、`SMILE`、`THUMBSUP`)和 4 个 VC 反馈 key(`VC_CanNotSee`、`VC_NoSound`、`VC_LooksGood`、`VC_SoundsClear`)。
|
||||
6. 不要编造列表外的 `emoji_type`,也不要把 natural language 硬编码成不存在的 key;如果用户只给语义,可在完整列表中选择最接近的 key,无法判断时先确认。
|
||||
7. 该命令只暴露会中文本和会中表情,不作为“发送绑定群消息”的默认能力;如果用户明确要发群聊,请路由到 [`lark-im`](../lark-im/SKILL.md)。
|
||||
8. 若使用应用身份发送,应用机器人必须在会中;若使用用户身份发送,当前用户必须正在该会议中。权限错误时按“应用身份权限配置检查”或“用户身份被拒绝时”处理。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type LOVE
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
|
||||
```
|
||||
|
||||
### 4. 离开会议(写操作)
|
||||
|
||||
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用应用身份执行 `+meeting-leave --as bot --meeting-id <长数字 meeting_id>`;不应因任务完成而执行离会。
|
||||
2. `--meeting-id` **必须**是长数字会议 ID,通常来自 `+meeting-join` 返回的 `meeting.id`,也可以来自应用身份 `+meeting-list-active` 返回的 `meeting_id`。如果来自 list-active,必须确认应用机器人当前就在该会中。**不接受 9 位会议号**。
|
||||
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
|
||||
4. 使用与入会或 active meeting 发现相同的应用身份离会。
|
||||
|
||||
### 4. 获取当前可用的进行中会议 ID(读操作)
|
||||
### 5. 获取当前可用的进行中会议 ID(读操作)
|
||||
|
||||
1. `+meeting-list-active` 用来发现当前进行中的会议,并拿到后续 `+meeting-events` 需要的长数字 `meeting_id`。
|
||||
2. 用户身份:`lark-cli vc +meeting-list-active --as user --format json`,用于发现当前登录用户正在参加的会议;后续 `+meeting-events` 继续 `--as user`。
|
||||
@@ -95,7 +115,7 @@ metadata:
|
||||
5. 如果返回多个会议,不要自动任选一个;按 `meeting_title` / `meeting_no` / `meeting_id` 展示候选,等待用户明确选择后再调用 `+meeting-events`。
|
||||
6. 如果用户给了 9 位会议号,先在 active meeting 结果中按 `meeting_no` 匹配。匹配失败时,不要自动入会;只有用户明确要求应用机器人真实入会时,才询问或执行 `+meeting-join`。
|
||||
|
||||
### 5. Agent 参会示范
|
||||
### 6. Agent 参会示范
|
||||
|
||||
```bash
|
||||
# 1. 入会,捕获 meeting.id
|
||||
@@ -136,11 +156,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
|
||||
| [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md) | 读 | List active meetings and discover meeting_id for event reads |
|
||||
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List meeting events visible to the app agent (participant joined/left, transcript, chat, share) |
|
||||
| [`+meeting-message-send`](references/lark-vc-agent-meeting-message-send.md) | 写 | Send an in-meeting text message or reaction emoji |
|
||||
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
|
||||
|
||||
- [`+meeting-join`](references/lark-vc-agent-meeting-join.md):入参格式、写操作可见性风险、入会失败排查。
|
||||
- [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md):用户身份和应用身份的不同返回范围。
|
||||
- [`+meeting-events`](references/lark-vc-agent-meeting-events.md):`meeting_id` 来源、身份延续、分页和错误码(10005 / 20001 / 20002)。
|
||||
- [`+meeting-message-send`](references/lark-vc-agent-meeting-message-send.md):会中文本、完整 `emoji_type` 列表、身份延续和写操作风险。
|
||||
- [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md):`meeting_id` 的来源与写操作可见性。
|
||||
|
||||
## 应用身份权限配置检查
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# vc +meeting-message-send
|
||||
|
||||
发送会中文本消息或会中 reaction emoji。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-message-send`(调用 `POST /open-apis/vc/v1/bots/message`)。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户要求“在会里发一句话”“提示大家”“给当前会议发消息”。
|
||||
- 用户要求发送会中表情,例如“发个点赞”“发个 OK”“发个爱心”。
|
||||
- 用户要求表达会中反馈,例如“听不到”“看不到”“声音清楚”“效果不错”。
|
||||
- 只用于正在进行中的会议;已结束会议不支持。
|
||||
|
||||
## 身份规则
|
||||
|
||||
`meeting_id` 从哪种身份路径拿到,发送消息时就沿用哪种身份:
|
||||
|
||||
| meeting_id 来源 | 发送时身份 |
|
||||
| --- | --- |
|
||||
| `+meeting-list-active --as user` | `+meeting-message-send --as user` |
|
||||
| `+meeting-list-active --as bot --user-id <user_open_id>` | `+meeting-message-send --as bot` |
|
||||
| `+meeting-join --as bot` 返回的 `meeting.id` | `+meeting-message-send --as bot` |
|
||||
|
||||
不要把用户身份发现的 `meeting_id` 改用应用身份发送,也不要把应用身份发现的 `meeting_id` 改用用户身份发送,除非用户明确要求切换。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--meeting-id` | 必填,长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| `--msg-type` | 可选,`text` 或 `reaction`;只传 `--text` 或只传 `--emoji-type` 时可自动推断 |
|
||||
| `--text` | 文本消息内容 |
|
||||
| `--emoji-type` | 会中 reaction emoji key,大小写敏感,必须从本文“完整 `emoji_type` 列表”中选择 |
|
||||
| `--uuid` | 可选,幂等 key;不传则服务端生成 |
|
||||
|
||||
CLI 会把 `--text` 或 `--emoji-type` 统一映射到 OpenAPI 请求体的 `content` 字段;`meeting_id` 也在请求体中传递。
|
||||
|
||||
## 文本消息
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
|
||||
```
|
||||
|
||||
文本消息会出现在会议内的文本互动区。不要把它当成绑定群消息发送能力;如果用户明确要求发到群聊,路由到 `lark-im`。
|
||||
|
||||
## 会中表情
|
||||
|
||||
会中 reaction 支持普通 Feishu reaction emoji,也支持 4 个 VC 反馈 key。
|
||||
|
||||
常见语义:
|
||||
|
||||
| 用户表达 | 推荐 `emoji_type` |
|
||||
| --- | --- |
|
||||
| 点赞、赞一下、认可 | `THUMBSUP` |
|
||||
| +1、加一、附议、同上 | `JIAYI` |
|
||||
| OK、好的 | `OK` |
|
||||
| 收到、了解 | `Get` |
|
||||
| 爱心、红心 | `HEART` |
|
||||
| 喜欢、爱了 | `LOVE` |
|
||||
| 比心 | `FINGERHEART` |
|
||||
| 看起来没问题、可以继续 | `LGTM` |
|
||||
| 搞定、已完成 | `DONE` |
|
||||
| -1、减一 | `MinusOne` |
|
||||
| 不赞同、踩 | `ThumbsDown` |
|
||||
| 听不到、没声音 | `VC_NoSound` |
|
||||
| 看不到、画面有问题 | `VC_CanNotSee` |
|
||||
| 声音清楚 | `VC_SoundsClear` |
|
||||
| 会议画面效果不错、画面看起来可以 | `VC_LooksGood` |
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type LOVE
|
||||
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
|
||||
```
|
||||
|
||||
不要编造列表外的 `emoji_type`,也不要把 mixed-case 值改成全大写,例如 `EatingFood`、`CheckMark`、`StatusInFlight` 都要按原值传。
|
||||
|
||||
如果用户给的是自然语言语义,可以在下方列表中选择语义最接近的 key;如果不确定,先向用户确认。
|
||||
|
||||
### 完整 `emoji_type` 列表
|
||||
|
||||
以下列表与 IM reaction 官方 emoji 列表保持一致,并额外包含 VC 会中特定反馈 key:
|
||||
|
||||
```text
|
||||
OK, THUMBSUP, THANKS, MUSCLE, FINGERHEART, APPLAUSE, FISTBUMP, JIAYI
|
||||
DONE, SMILE, BLUSH, LAUGH, SMIRK, LOL, FACEPALM, LOVE
|
||||
WINK, PROUD, WITTY, SMART, SCOWL, THINKING, SOB, CRY
|
||||
ERROR, NOSEPICK, HAUGHTY, SLAP, SPITBLOOD, TOASTED, GLANCE, DULL
|
||||
INNOCENTSMILE, JOYFUL, WOW, TRICK, YEAH, ENOUGH, TEARS, EMBARRASSED
|
||||
KISS, SMOOCH, DROOL, OBSESSED, MONEY, TEASE, SHOWOFF, COMFORT
|
||||
CLAP, PRAISE, STRIVE, XBLUSH, SILENT, WAVE, WHAT, FROWN
|
||||
SHY, DIZZY, LOOKDOWN, CHUCKLE, WAIL, CRAZY, WHIMPER, HUG
|
||||
BLUBBER, WRONGED, HUSKY, SHHH, SMUG, ANGRY, HAMMER, SHOCKED
|
||||
TERROR, PETRIFIED, SKULL, SWEAT, SPEECHLESS, SLEEP, DROWSY, YAWN
|
||||
SICK, PUKE, BETRAYED, HEADSET, EatingFood, MeMeMe, Sigh, Typing
|
||||
Lemon, Get, LGTM, OnIt, OneSecond, VRHeadset, YouAreTheBest, SALUTE
|
||||
SHAKE, HIGHFIVE, UPPERLEFT, ThumbsDown, SLIGHT, TONGUE, EYESCLOSED, RoarForYou
|
||||
CALF, BEAR, BULL, RAINBOWPUKE, ROSE, HEART, PARTY, LIPS
|
||||
BEER, CAKE, GIFT, CUCUMBER, Drumstick, Pepper, CANDIEDHAWS, BubbleTea
|
||||
Coffee, Yes, No, OKR, CheckMark, CrossMark, MinusOne, Hundred
|
||||
AWESOMEN, Pin, Alarm, Loudspeaker, Trophy, Fire, BOMB, Music
|
||||
XmasTree, Snowman, XmasHat, FIREWORKS, 2022, REDPACKET, FORTUNE, LUCK
|
||||
FIRECRACKER, StickyRiceBalls, HEARTBROKEN, POOP, StatusFlashOfInspiration, 18X, CLEAVER, Soccer
|
||||
Basketball, GeneralDoNotDisturb, Status_PrivateMessage, GeneralInMeetingBusy, StatusReading, StatusInFlight, GeneralBusinessTrip, GeneralWorkFromHome
|
||||
StatusEnjoyLife, GeneralTravellingCar, StatusBus, GeneralSun, GeneralMoonRest, MoonRabbit, Mooncake, JubilantRabbit
|
||||
TV, Movie, Pumpkin, BeamingFace, Delighted, ColdSweat, FullMoonFace, Partying
|
||||
GoGoGo, ThanksFace, SaluteFace, Shrug, ClownFace, HappyDragon
|
||||
VC_CanNotSee, VC_NoSound, VC_LooksGood, VC_SoundsClear
|
||||
```
|
||||
|
||||
## 9 位会议号处理
|
||||
|
||||
如果用户给的是 9 位会议号并要求发送会中消息:
|
||||
|
||||
1. 先按当前身份执行 `+meeting-list-active`。
|
||||
2. 在返回结果中按 `meeting_no` 匹配该 9 位会议号。
|
||||
3. 匹配到唯一会议后取长数字 `meeting_id`。
|
||||
4. 用发现该会议时的同一身份执行 `+meeting-message-send`。
|
||||
|
||||
匹配失败时不要自动入会。只有用户明确要求“让应用机器人入会/旁听/代参会”时,才改用 `+meeting-join`。
|
||||
|
||||
## 权限和前置条件
|
||||
|
||||
- 用户身份:当前用户必须正在该会议中。
|
||||
- 应用身份:应用机器人必须正在该会议中。
|
||||
- 会议需要开启会中智能体/Agent 能力开关。
|
||||
- 需要 `vc:meeting.message:write` 权限;应用身份还需要应用已安装、数据范围已配置。
|
||||
|
||||
应用身份权限错误时,不要引导用户反复 `auth login`。按主 skill 的“应用身份权限配置检查”处理。
|
||||
|
||||
## 相关
|
||||
|
||||
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前进行中会议 ID
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 读取会中事件
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 应用机器人入会
|
||||
113
tests/cli_e2e/vc/vc_meeting_message_send_dryrun_test.go
Normal file
113
tests/cli_e2e/vc/vc_meeting_message_send_dryrun_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestVCMeetingMessageSendDryRun(t *testing.T) {
|
||||
setVCMeetingMessageSendDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMsgType string
|
||||
wantContent string
|
||||
wantUUID string
|
||||
}{
|
||||
{
|
||||
name: "text",
|
||||
args: []string{
|
||||
"vc", "+meeting-message-send",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello from dry-run",
|
||||
"--uuid", "cid-dryrun-text",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsgType: "text",
|
||||
wantContent: "hello from dry-run",
|
||||
wantUUID: "cid-dryrun-text",
|
||||
},
|
||||
{
|
||||
name: "reaction",
|
||||
args: []string{
|
||||
"vc", "+meeting-message-send",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--msg-type", "reaction",
|
||||
"--emoji-type", "VC_NoSound",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsgType: "reaction",
|
||||
wantContent: "VC_NoSound",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, int64(1), gjson.Get(out, "api.#").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/vc/v1/bots/message", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "7651377260537433044", gjson.Get(out, "api.0.body.meeting_id").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantMsgType, gjson.Get(out, "api.0.body.msg_type").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantContent, gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out)
|
||||
if tt.wantUUID == "" {
|
||||
require.False(t, gjson.Get(out, "api.0.body.uuid").Exists(), "stdout:\n%s", out)
|
||||
} else {
|
||||
require.Equal(t, tt.wantUUID, gjson.Get(out, "api.0.body.uuid").String(), "stdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCMeetingMessageSendDryRunRejectsLongUUID(t *testing.T) {
|
||||
setVCMeetingMessageSendDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"vc", "+meeting-message-send",
|
||||
"--meeting-id", "7651377260537433044",
|
||||
"--text", "hello from dry-run",
|
||||
"--uuid", strings.Repeat("u", 129),
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "--uuid", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "--uuid is too long", "stderr:\n%s", result.Stderr)
|
||||
require.Empty(t, result.Stdout)
|
||||
}
|
||||
|
||||
func setVCMeetingMessageSendDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_meeting_message_send_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_meeting_message_send_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
Reference in New Issue
Block a user