Compare commits

..

13 Commits

Author SHA1 Message Date
zhaojunlin.0405
fa66195826 test: assert typed subtype/param in mail +triage data-format rejection
Align TestMailTriageRejectsRemovedDataFormat with the repo *_test.go
guideline (error-path tests assert typed errs.ProblemOf metadata —
category/subtype/param — not message substrings alone), matching the
sibling TestMailTriageEnumRejectsUnknownFormat. Addresses CodeRabbit
review on PR #1830.
2026-07-10 11:26:58 +08:00
zhaojunlin.0405
afd4555892 docs: fix stale default-format usage comments in mail triage/watch 2026-07-10 10:35:03 +08:00
zhaojunlin.0405
d826704b6f test: cover --format data rejection; emit count line for all formats 2026-07-10 09:57:13 +08:00
zhaojunlin.0405
d70c87be7d docs: rewrite mail +triage --format reference for ok/data envelope 2026-07-10 09:49:14 +08:00
zhaojunlin.0405
e24252a5fb feat: retain mail +triage --format enum and emit read hints for all formats 2026-07-10 09:46:18 +08:00
zhaojunlin.0405
d8dcd5394f test: adapt mail +triage tests to ok/data envelope output 2026-07-09 21:44:50 +08:00
zhaojunlin.0405
d8997ce19e feat: standardize mail +triage output to ok/data envelope 2026-07-09 21:41:19 +08:00
zhaojunlin.0405
47a97cc249 feat: add has_more/page_token pagination fields to output.Meta 2026-07-09 21:40:16 +08:00
zhaojunlin.0405
cc56cc5665 test: cover mail +watch default json envelope output 2026-07-09 17:20:42 +08:00
zhaojunlin.0405
c02fa92667 docs: update mail triage/watch --format default to json 2026-07-09 16:05:51 +08:00
zhaojunlin.0405
49f60d9239 feat: default mail +triage/+watch output to json 2026-07-09 16:05:51 +08:00
yballul-bytedance
74d8458635 feat(drive): Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-09 11:49:17 +08:00
fangshuyu-768
80fadf1801 fix(drive): abort push on parent sibling limit (#1813) 2026-07-09 11:29:38 +08:00
37 changed files with 448 additions and 201 deletions

View File

@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},

View File

@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied

View File

@@ -15,8 +15,10 @@ type Envelope struct {
// Meta carries optional metadata in envelope responses.
type Meta struct {
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
HasMore bool `json:"has_more,omitempty"`
PageToken string `json:"page_token,omitempty"`
}
// PendingNotice, if set, returns system-level notices to inject as the

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"strings"
"testing"
)
func TestMetaPaginationFieldsOmitempty(t *testing.T) {
// 有分页 → 出现 has_more/page_token
b, _ := json.Marshal(Meta{Count: 2, HasMore: true, PageToken: "list:abc"})
s := string(b)
if !strings.Contains(s, `"has_more":true`) || !strings.Contains(s, `"page_token":"list:abc"`) {
t.Fatalf("expected pagination fields, got %s", s)
}
// 零值 → omitempty 不出现
b2, _ := json.Marshal(Meta{Count: 1})
if strings.Contains(string(b2), "has_more") || strings.Contains(string(b2), "page_token") {
t.Fatalf("zero pagination must be omitted, got %s", b2)
}
}

View File

@@ -623,6 +623,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Code == 1062507:
decision.Class = "parent_sibling_limit"
decision.Terminal = true
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:

View File

@@ -1334,6 +1334,75 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
}
}
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(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": 1062507,
"msg": "parent node out of sibling num.",
},
})
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"] != "parent_sibling_limit" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -32,24 +32,6 @@ func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
}
}
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage --json returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
}
if !strings.Contains(stdout.String(), `"messages"`) {
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
}
}
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
f1, stdout1, _, _ := mailShortcutTestFactory(t)
@@ -106,7 +88,36 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
t.Fatalf("message = %q, want enum validation message", problem.Message)
}
if !strings.Contains(problem.Message, "table, json, data") {
if !strings.Contains(problem.Message, "json, pretty, table, ndjson, csv") {
t.Fatalf("message = %q, want allowed values list", problem.Message)
}
}
// 回归:`data` 曾是合法取值envelope 化后从 Enum 移除,应被硬拒(而非静默降级)
func TestMailTriageRejectsRemovedDataFormat(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "data", "--max", "1", "--dry-run"}, f, stdout)
if err == nil {
t.Fatal("expected validation error for removed --format data")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T, want typed errs problem carrier", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if ve.Param != "--format" {
t.Fatalf("param = %q, want --format", ve.Param)
}
if !strings.Contains(problem.Message, `invalid value "data" for --format`) {
t.Fatalf("message = %q, want data rejection", problem.Message)
}
}

View File

@@ -94,7 +94,7 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--max", "1",
"+triage", "--format", "table", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
@@ -113,23 +113,6 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
}
}
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
}
}
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
@@ -55,7 +56,7 @@ var MailTriage = common.Shortcut{
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "format", Default: "json", Enum: []string{"json", "pretty", "table", "ndjson", "csv"}, Desc: "output format: json (default) | pretty | table | ndjson | csv"},
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
{Name: "page-size", Type: "int", Desc: "alias for --max"},
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
@@ -138,7 +139,6 @@ var MailTriage = common.Shortcut{
}
mailbox := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailbox)
outFormat := runtime.Str("format")
query := runtime.Str("query")
if query != "" {
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
@@ -277,26 +277,21 @@ var MailTriage = common.Shortcut{
msg["mailbox_id"] = mailbox
}
switch outFormat {
case "json", "data":
outData := map[string]interface{}{
"messages": messages,
"mailbox_id": mailbox,
"count": len(messages),
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
// notice 一律走 stderr不再随 json 带内返回)
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
// 标准信封输出data = messages 数组(每条已含 mailbox_id
// meta 带 count/has_more/page_token--format pretty 走精排表格。
runtime.OutFormat(messages, &output.Meta{
Count: len(messages),
HasMore: hasMore,
PageToken: nextPageToken,
}, func(w io.Writer) {
if len(messages) == 0 {
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil
fmt.Fprintln(w, "No messages found.")
return
}
var rows []map[string]interface{}
for _, msg := range messages {
@@ -314,29 +309,31 @@ var MailTriage = common.Shortcut{
}
rows = append(rows, row)
}
output.PrintTable(runtime.IO().Out, rows)
fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
if hasMore && nextPageToken != "" {
var hint strings.Builder
hint.WriteString("next page: mail +triage")
if mailbox != "me" {
hint.WriteString(" --mailbox " + shellQuote(mailbox))
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
output.PrintTable(w, rows)
})
// 人类导航提示走 stderr所有格式一致不污染 stdout 的数据)
fmt.Fprintf(runtime.IO().ErrOut, "%d message(s)\n", len(messages))
if hasMore && nextPageToken != "" {
var hint strings.Builder
hint.WriteString("next page: mail +triage")
if mailbox != "me" {
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
hint.WriteString(" --mailbox " + shellQuote(mailbox))
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
}
return nil
},

View File

@@ -1548,9 +1548,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
wantCount: 2,
},
{
name: "list data public mailbox",
name: "list json public mailbox",
mailbox: "shared@company.com",
format: "data",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageListStub(reg, mailbox, []string{"msg_pub_001"}, false, "")
@@ -1574,7 +1574,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
},
{
name: "empty list json keeps top-level mailbox",
name: "empty list json returns empty data array with count 0",
mailbox: "me",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
@@ -1587,7 +1587,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
f, stdout, stderr, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
tt.register(reg, tt.mailbox)
@@ -1603,11 +1603,8 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
data := decodeMailTriageJSONOutput(t, stdout)
if data["mailbox_id"] != tt.mailbox {
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
}
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
if tt.wantNotice != "" && !strings.Contains(stderr.String(), "notice: "+tt.wantNotice) {
t.Fatalf("notice mismatch: got %q, want %q in stderr", stderr.String(), tt.wantNotice)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
@@ -1655,6 +1652,40 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
}
}
// TestMailTriageDefaultFormatIsJSON verifies that with no --format flag the
// command defaults to json and prints the paginated object to stdout, not the
// human table.
func TestMailTriageDefaultFormatIsJSON(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", []string{"msg_ok"}, false, "")
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
mailTriageBatchMessage("msg_ok", "Present"),
})
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--filter", `{"folder_id":"INBOX"}`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
if data["ok"] != true {
t.Fatalf("default output must be ok/data envelope, got %#v", data)
}
messages := mailTriageMessagesFromOutput(t, data) // reads .data
if len(messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(messages))
}
meta := mailTriageMetaFromOutput(t, data)
if _, ok := meta["count"]; !ok {
t.Fatalf("meta.count missing: %#v", meta)
}
}
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
@@ -1678,7 +1709,7 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
mailTriageBatchMessage("msg_001", "Table message"),
})
args := []string{"+triage", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
args := []string{"+triage", "--format", "pretty", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
if tt.mailbox != "me" {
args = append(args, "--mailbox", tt.mailbox)
}
@@ -1719,6 +1750,7 @@ func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--format", "pretty",
"--query", strings.Repeat("q", 81),
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1745,21 +1777,31 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
// mailTriageMessagesFromOutput extracts triage messages as object maps.
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
rawMessages, ok := data["messages"].([]interface{})
rawMessages, ok := data["data"].([]interface{})
if !ok {
t.Fatalf("messages type mismatch: %T", data["messages"])
t.Fatalf("data (messages array) type mismatch: %T", data["data"])
}
messages := make([]map[string]interface{}, 0, len(rawMessages))
for i, item := range rawMessages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("messages[%d] type mismatch: %T", i, item)
t.Fatalf("data[%d] type mismatch: %T", i, item)
}
messages = append(messages, msg)
}
return messages
}
// mailTriageMetaFromOutput extracts the envelope's meta object.
func mailTriageMetaFromOutput(t *testing.T, data map[string]interface{}) map[string]interface{} {
t.Helper()
meta, _ := data["meta"].(map[string]interface{})
if meta == nil {
t.Fatalf("meta missing in envelope: %#v", data)
}
return meta
}
func registerMailTriageListStub(reg *httpmock.Registry, mailbox string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
@@ -1960,7 +2002,8 @@ func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
if len(messages) != 5 {
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
}
if got := data["has_more"]; got != false {
meta := mailTriageMetaFromOutput(t, data)
if got, ok := meta["has_more"]; ok && got == true {
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
}
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are

View File

@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "format", Default: "json", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
@@ -385,12 +385,7 @@ var MailWatch = common.Shortcut{
}
}
switch outFormat {
case "json", "":
output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
case "data":
output.PrintNdjson(out, outputData)
}
output.PrintNdjson(out, watchOutputValue(outFormat, string(runtime.As()), outputData))
}
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
@@ -687,6 +682,17 @@ func minimalWatchMessage(message map[string]interface{}) map[string]interface{}
return out
}
// watchOutputValue selects the per-event value that +watch prints as NDJSON:
// "data" emits the bare payload; every other format (json — the default) wraps
// it in an ok/identity/data envelope. Extracted from Execute so the default
// envelope behavior is unit-testable without a live WebSocket.
func watchOutputValue(outFormat, identity string, outputData interface{}) interface{} {
if outFormat == "data" {
return outputData
}
return output.Envelope{OK: true, Identity: identity, Data: outputData}
}
func watchFetchFailureValue(messageID, fetchFormat string, err error, eventBody map[string]interface{}) map[string]interface{} {
payload := map[string]interface{}{
"ok": false,

View File

@@ -885,3 +885,59 @@ func dryRunAPIsForMailWatchTest(t *testing.T, dry *common.DryRunAPI) []struct {
}
return payload.API
}
// TestWatchOutputValueDefaultIsJSONEnvelope verifies +watch's default format
// (json) wraps each event in an ok/identity/data envelope, while --format data
// emits the bare payload. Covers the default-format behavior without a live
// WebSocket (addresses the coderabbitai review ask).
func TestWatchOutputValueDefaultIsJSONEnvelope(t *testing.T) {
payload := map[string]interface{}{"message": map[string]interface{}{"message_id": "m1"}}
// default (json) → ok/identity/data envelope
b, err := json.Marshal(watchOutputValue("json", "user", payload))
if err != nil {
t.Fatalf("marshal json value: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(b, &env); err != nil {
t.Fatalf("unmarshal json value: %v", err)
}
if env["ok"] != true {
t.Fatalf("default json must have ok:true, got %s", b)
}
if env["identity"] != "user" {
t.Fatalf("default json must carry identity, got %s", b)
}
if _, ok := env["data"]; !ok {
t.Fatalf("default json must have data field, got %s", b)
}
// empty format (safety) also defaults to the json envelope
b3, err := json.Marshal(watchOutputValue("", "bot", payload))
if err != nil {
t.Fatalf("marshal empty-format value: %v", err)
}
var env3 map[string]interface{}
if err := json.Unmarshal(b3, &env3); err != nil {
t.Fatalf("unmarshal empty-format value: %v", err)
}
if env3["ok"] != true {
t.Fatalf("empty format should default to json envelope, got %s", b3)
}
// --format data → bare payload, no envelope
b2, err := json.Marshal(watchOutputValue("data", "user", payload))
if err != nil {
t.Fatalf("marshal data value: %v", err)
}
var bare map[string]interface{}
if err := json.Unmarshal(b2, &bare); err != nil {
t.Fatalf("unmarshal data value: %v", err)
}
if _, hasOK := bare["ok"]; hasOK {
t.Fatalf("--format data must be bare (no envelope), got %s", b2)
}
if _, hasMsg := bare["message"]; !hasMsg {
t.Fatalf("--format data payload should carry message, got %s", b2)
}
}

View File

@@ -76,4 +76,4 @@ CLI 提供三种互斥的 scope 表达方式:
## 不在本 skill 范围
- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。
- 身份选择、权限不足处理(`missing_scopes`→`console_url`、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
- 身份选择、权限不足处理(`permission_violations`→`console_url`、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。

View File

@@ -85,7 +85,7 @@ metadata:
## 身份与权限降级
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`
- user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次bot 仍失败就停止重试并按权限错误处理。
- `91403` 或明确不可访问错误不要循环换身份重试。
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -21,6 +21,8 @@ metadata:
## 快速决策
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
@@ -162,7 +164,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
>
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)模板通过 `--params` 传 `folder_token` / `page_token`并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
### files
@@ -204,10 +206,12 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### file.statistics
- `get` — 获取文件统计信息
- 获取 docx / 文件统计信息时,建议优先使用 typed flags`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json``--params` JSON 也支持,适合批量拼装或 raw 参数场景。
### file.view_records
- `list` — 获取文档的访问者记录
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json``--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
### file.comment.reply.reactions

View File

@@ -7,6 +7,18 @@
> [!CAUTION]
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
## 删除前门槛
执行 `drive +delete --yes` 前同时满足:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token或用户确认过且可解析的资源列表 |
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
若缺少任一条件,使用 `drive +search``drive +inspect` 或只读 API 收集候选并回复待确认清单启发式规则打开时间、标题模式、owner、文件类型等只能作为候选筛选依据不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token``--type`
## 命令

View File

@@ -41,12 +41,37 @@ lark-cli drive files list \
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
## 按时间排序
默认不要传 `order_by` / `direction`;服务端会按默认顺序返回。只有用户明确要求按创建时间或编辑时间排序时,才使用服务端排序参数。
按创建时间升序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"CreatedTime","direction":"ASC","page_size":200}' \
--format json
```
按编辑时间降序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"EditedTime","direction":"DESC","page_size":200}' \
--format json
```
以上示例返回排序后的当前页;如果返回 `has_more=true`,保持相同 `folder_token` / `order_by` / `direction` / `page_size`,把 `next_page_token` 放入 `page_token` 继续翻页。
## 参数规则
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构
3. 默认不要传 `order_by` / `direction`;只有用户明确要求按创建时间 / 编辑时间排序时才使用服务端排序参数
4. 排序参数映射:创建时间 -> `order_by:"CreatedTime"`;编辑时间 / 修改时间 -> `order_by:"EditedTime"`;升序 -> `direction:"ASC"`;降序 -> `direction:"DESC"`。不要省略排序参数后再用 Python / shell 客户端排序替代
5. 排序查询建议带 `page_size:200` 减少翻页;只有用户要求完整分页、递归盘点、大目录全量导出,或当前页返回 `has_more=true` 后继续翻页时,才加入 `page_token`
6. `page_size` 在分页、递归盘点或全量导出时建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
7. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
## 返回结构与解析

View File

@@ -47,4 +47,6 @@ JSON 输出包含以下字段:
- `--url` 为必填参数
-`--url` 是 bare token非完整 URL`--type` 也是必填的
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type``token` 是底层文档的类型和 token
- `+inspect` 只用于识别/消歧;如果任务已能通过 URL 路径形态完成路由判断,不必把它作为所有 Drive 操作的通用前置步骤
- `+inspect` 失败后不要自动切到写接口继续尝试先按错误提示处理权限、scope 或链接问题
- 支持 `--dry-run` 查看将调用的 API 步骤

View File

@@ -54,7 +54,7 @@ lark-cli drive +member-add \
}
```
批量部分失败时,`partial``true`同一份结果以 `ok:false` 部分失败信封写到 **stdout**stderr 不再输出单独的错误信封CLI 以非零退出码结束。检查 `data` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
批量部分失败时,`partial``true`CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
## 行为说明

View File

@@ -10,6 +10,18 @@
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
## 公开权限修改前门槛
公开权限修改是高风险写操作。执行 `drive permission.public patch --yes` 前同时确认:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个 URL/token或用户确认过的资源列表 |
| 公开范围 | 用户明确选择组织内/互联网、可读/可编辑等具体 `link_share_entity` 档位 |
| 执行确认 | 用户在本轮确认按该目标和范围执行 |
“开放一下”“共享给大家”“让大家能看”只表达目标状态不包含具体公开范围。先列出可选范围并停止等待用户选择公开档位必须来自用户选择CLI 的 `--yes` 只表示已获得用户对该档位的执行确认。
## 公开权限错误码
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope它们通常表示租户、对外分享或文档密级策略拦截。

View File

@@ -17,11 +17,11 @@
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error` |
`summary.failed > 0` 时命令以 **非零状态码**`exit=1`)退出同一份 `summary + items` `ok:false` 部分失败信封写到 **stdout**(字段在 `data.summary` / `data.items`stderr 不再输出单独的错误信封;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
`summary.failed > 0` 时命令以 **非零状态码**`exit=1``error.type=partial_failure`)退出,且同一份 `summary + items` `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation``error.subtype=failed_precondition``error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
@@ -80,7 +80,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
- `--delete-local --yes`**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `ok:false` 部分失败结果非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
- 不传 `--delete-local``summary.deleted_local` 永远是 0命令对本地"多余"文件视而不见。

View File

@@ -24,7 +24,7 @@
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation``error.subtype=failed_precondition``error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
@@ -143,6 +143,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试检查目标文件夹权限、身份类型user / bot和资源可见性 |
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
| `parent_sibling_limit` | `1062507` | 目标父文件夹单层子节点数量超过上限 | 停止重试,清理目标目录、换一个 `--folder-token`,或把上传内容拆到多个子目录 |
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |

View File

@@ -19,7 +19,7 @@
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,在 stderr 返回类型化错误信封(`error.type=validation``error.subtype=failed_precondition``error.params[]` 每条的 `name` 是冲突的 `rel_path``reason` 枚举该路径下所有碰撞条目(`type` + `file_token`。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token``type`、名称、大小和时间字段;其中 `created_time``modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
## 命令
@@ -76,18 +76,20 @@ lark-cli drive +status \
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "validation",
"subtype": "failed_precondition",
"message": "1 rel_path(s) map to multiple Drive entries",
"hint": "resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help",
"params": [
{
"name": "dup.txt",
"reason": "2 Drive entries collide here: file <full_file_token>, folder <folder_token>"
}
]
"type": "duplicate_remote_path",
"message": "multiple Drive entries map to the same rel_path",
"detail": {
"duplicates_remote": [
{
"rel_path": "dup.txt",
"entries": [
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
]
}
]
}
}
}
```

View File

@@ -8,7 +8,7 @@
## 用法
```bash
# 默认:收件箱邮件(默认 20 条,默认table 格式
# 默认:收件箱邮件(默认 20 条,默认 json 信封输出
lark-cli mail +triage
# 查看收件箱未读
@@ -32,11 +32,11 @@ lark-cli mail +triage --filter '{"label":"important"}'
lark-cli mail +triage --filter '{"label":"重要邮件"}'
# json/data 格式可配合 jq 处理
lark-cli mail +triage --format json | jq '.messages[].subject'
lark-cli mail +triage --format json | jq '.data[].subject'
# 分页:先取 10 条,再用 page_token 翻页
lark-cli mail +triage --max 10 --format json
# 输出中包含 page_token传入下一次请求
# 输出 meta 中包含 page_token传入下一次请求
lark-cli mail +triage --page-token 'list:FfccvoqPd...' --max 10 --format json
# --page-size 是 --max 的别名
@@ -49,7 +49,7 @@ lark-cli mail +triage --page-size 10
|------|------|------|
| `--filter <json>` | — | 筛选条件(见下方字段说明) |
| `--query <text>` | — | 全文搜索关键词 |
| `--format <mode>` | `table` | `table` / `json` / `data``json``data` 均输出含分页信息的对象) |
| `--format <mode>` | `json` | `json`(默认,`{ok,data,meta}` 信封)/ `pretty`(人类表格)/ `table`·`csv`·`ndjson`(通用渲染);非 Enum 值报错 |
| `--max <n>` | `20` | 最大返回条数1-400内部自动分页拉取 |
| `--page-size <n>` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 |
| `--page-token <token>` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:``list:` 前缀,标识来源路径,不可混用 |
@@ -78,13 +78,14 @@ lark-cli mail +triage --page-size 10
## 输出
### `--format json` / `--format data`
### `--format json`(默认)
两者输出格式相同,均为含分页信息的对象
标准 `{ok,data,meta}` 信封;`data` 为 message 数组(每条含 `mailbox_id`),分页信息在 `meta`
```json
{
"messages": [
"ok": true,
"data": [
{
"message_id": "SEU2...",
"mailbox_id": "me",
@@ -94,32 +95,25 @@ lark-cli mail +triage --page-size 10
"labels": "INBOX,UNREAD"
}
],
"mailbox_id": "me",
"count": 20,
"has_more": true,
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
"meta": { "count": 20, "has_more": true, "page_token": "list:FfccvoqPd_loLhtcRx8cx..." }
}
```
- `mailbox_id`当前邮箱标识,用于传递`mail +message --mailbox` 以保持公共邮箱上下文
- `has_more`:是否还有下一页
- `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾
- token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用
- `data[].mailbox_id`:邮箱标识,`mail +message --mailbox` 以保持公共邮箱上下文
- `meta.count`:本次返回条数
- `meta.has_more`:是否还有下一页(无更多时字段省略)
- `meta.page_token`:传入 `--page-token` 获取下一页(无更多时省略);前缀 `search:` / `list:` 标识来源路径,不可混用
- **迁移**:旧的顶层 `.messages` / `.count` / `.has_more` / `.page_token` 已迁到 `.data` / `.meta.*``--format data` 已移除(用默认或 `--format json`
### `table` 格式
### `pretty` / `table` / `csv` / `ndjson`
`page_token` 信息输出在 stderr自动携带 `--query`/`--filter`/`--mailbox` 参数方便续页
`--format pretty` 输出精排人类表格;`--format table` / `csv` / `ndjson` 用通用格式化器把 `data` 渲染成表格 / CSV / NDJSON。导航提示计数、下一页、读全文 tip统一输出到 **stderr**(所有格式一致,不污染 stdout 数据)
```text
15 message(s)
next page: mail +triage --query '合同审批' --page-token 'search:abc123...'
tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
```text
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
tip: read full content: single message use mail +message --mailbox 'shared@example.com' --message-id <id>; multiple messages use mail +messages --mailbox 'shared@example.com' --message-ids <id1>,<id2>,<id3>
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中。
### 搜索分页注意事项

View File

@@ -10,7 +10,7 @@
## 命令
```bash
# 默认:表格输出 message 元数据
# 默认:json 信封NDJSON 流)输出 message 元数据
lark-cli mail +watch
# 仅输出 message 数据jq 友好)
@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|------|------|------|
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--format <mode>` | `json` | 输出样式:`json`默认,带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |

View File

@@ -120,9 +120,9 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
**无编辑权限**:若 CLI 返回 `error.subtype=permission_denied`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回 `error.subtype=not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。

View File

@@ -126,8 +126,8 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` |
| `--todos` 与单条 flags 冲突 | 二选一 |
| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 |
| `error.subtype` = `permission_denied` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
| 缺少 OAuth scope`error.missing_scopes``minutes:minutes:update` | `lark-cli auth login --scope "minutes:minutes:update"` |
| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
| 缺少 OAuth scope`permission_violations``minutes:minutes:update` | `lark-cli auth login --scope "minutes:minutes:update"` |
## 参考

View File

@@ -69,7 +69,7 @@ LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli a
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
错误响应中包含关键信息:
- `missing_scopes`:列出缺失的 scope (N选1)
- `permission_violations`:列出缺失的 scope (N选1)
- `console_url`:飞书开发者后台的权限配置链接
- `hint`:建议的修复命令
@@ -178,22 +178,22 @@ lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门
```json
{
"ok": false,
"identity": "bot",
"error": {
"type": "confirmation",
"subtype": "confirmation_required",
"type": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": "high-risk-write",
"action": "drive +delete"
"risk": {
"level": "high-risk-write",
"action": "drive +delete"
}
}
}
```
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation"``error.subtype == "confirmation_required"`
2. **向用户确认**:把 `error.action``error.risk` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁

View File

@@ -65,15 +65,15 @@ lark-cli slides xml_presentations get --as user --params '{
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 3,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
}
},
"msg": "success"
}
```
@@ -94,12 +94,12 @@ lark-cli slides xml_presentation.slide create --as user --params '{
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
}
},
"msg": "success"
}
```
@@ -116,11 +116,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"revision_id": 101
}
},
"msg": "success"
}
```

View File

@@ -66,8 +66,7 @@ lark-cli slides +screenshot --as user \
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"output_dir": ".lark-slides/screenshots",
@@ -80,7 +79,8 @@ lark-cli slides +screenshot --as user \
"size": 12345
}
]
}
},
"msg": "success"
}
```

View File

@@ -141,12 +141,12 @@ lark-cli slides xml_presentation.slide create --as user \
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
}
},
"msg": "success"
}
```

View File

@@ -61,11 +61,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presenta
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"revision_id": 100
}
},
"msg": "success"
}
```

View File

@@ -65,15 +65,15 @@ lark-cli slides xml_presentation.slide get --as user --params '{
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"slide": {
"slide_id": "slide_example_id",
"content": "<slide id=\"slide_example_id\"><style/><data>...</data></slide>"
},
"revision_id": 100
}
},
"msg": "success"
}
```

View File

@@ -130,28 +130,24 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"revision_id": 105
}
},
"msg": "success"
}
```
### 失败(任一 part 失败,整批不生效)
失败时命令以非零退出码结束stderr 返回类型化错误信封(`error.code`(如 3350001/ `error.message` / `error.hint`stdout 不会打印后端原始响应
失败时返回非零错误码(如 3350001。若后端能定位失败的 part`data` 中可能附带
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "api",
"subtype": "...",
"code": 3350001,
"message": "...",
"hint": "..."
"code": 3350001,
"data": {
"failed_part_index": 0,
"failed_reason": "block not found"
}
}
```
@@ -159,6 +155,8 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.revision_id` | integer | 成功时返回更新后最新版本号 |
| `data.failed_part_index` | integer | 失败的 part 在 `parts` 数组中的索引(从 0 起) |
| `data.failed_reason` | string | 失败原因 |
## 常见错误

View File

@@ -56,15 +56,15 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
```json
{
"ok": true,
"identity": "user",
"code": 0,
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 1,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
}
},
"msg": "success"
}
```

View File

@@ -18,7 +18,7 @@ metadata:
## 内测提示
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `missing_scopes`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`
## 定位
@@ -170,7 +170,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
## 应用身份权限配置检查
应用身份 `--as bot``no permission``missing required scope(s)``missing_scopes``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
应用身份 `--as bot``no permission``missing required scope(s)``permission_violations``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
1. 以 CLI 返回的 metadata / error envelope 为准,确认提示的 VC Agent 相关权限已开通。常见读取 active meeting / events 需要会中事件读取权限;应用机器人入会 / 离会需要 bot 入会写权限。
2. 应用已发布并安装到当前租户。

View File

@@ -9,7 +9,7 @@
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.