Compare commits

...

11 Commits

Author SHA1 Message Date
zhanghuanxu
2b7c02cc7a feat: add iconpark lookup for lark slides 2026-06-04 21:44:33 +08:00
zhanghuanxu
2a5e4237f8 feat(slides):slide screenshot 2026-06-04 21:43:44 +08:00
zhoujunteng-max
ac116e7ca3 feat(drive): add drive preview and cover shortcuts and document quota details (#1259)
* feat: support get quota detail

* feat: add drive preview and cover shortcuts

- add `drive +preview` and `drive +cover` shortcuts
- wrap `preview_result` output with stable preview item fields
- support cover download via `preview_download` with validated preset mappings
- update lark-drive skill references for preview and cover usage

* fix(drive): classify cover 404 as failed precondition

* fix(drive): show preview download step in dry-run

* docs(drive): clarify quota details user-only usage

* fix(drive): soften cover 404 guidance
2026-06-04 21:08:59 +08:00
evandance
5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00
liangshuo-1
493b3cce95 chore(release): v1.0.48 (#1270) 2026-06-04 20:49:54 +08:00
zhangheng023
abc0553f21 fix: use json skills list during update (#1251)
* fix: use json skills list during update

* fix: preserve versioned skill names
2026-06-04 19:19:26 +08:00
xukuncx
a82a486508 feat(mail): preserve mailbox context in +triage output for public mailboxes (#1238)
When triaging a public/shared mailbox, downstream AI consumers (e.g.
mail +message) need the mailbox_id to construct correct API paths.
Previously the triage output only included message_id, causing
/user_mailboxes/me/messages/{id} lookups that fail for public mailboxes.

- Add mailbox_id field to every normalized message in structured output
- Add mailbox_id to top-level JSON/data output envelope
- Add mailbox_id to table rows when mailbox is not "me"
- Update stderr next-step tip to include --mailbox for non-me mailboxes
- Update next-page hint to include --mailbox for non-me mailboxes
- Add unit tests covering list, search, and public mailbox paths
- Update triage skill docs to show mailbox_id in output examples
2026-06-04 18:27:13 +08:00
YH-1600
c000dc3a44 docs: refine lark-drive knowledge organize workflow (#1253)
Change-Id: I49b4f398d60c5bb073d6c8d61987bd16f1a29c4e
2026-06-04 15:31:46 +08:00
zhicong666-bytedance
256df8c0fb docs(vc-agent): require explicit leave request (#1260) 2026-06-04 14:33:57 +08:00
Huangwenbo-wb
7a0dbe057b docs(slides): add whiteboard element documentation and improve slide guidance (#1029)
* feat(slides): add whiteboard element support and reference documentation

- Add lark-slides-whiteboard.md covering SVG and Mermaid modes, routing
  rules, layout examples, known issues, and self-check checklist
- Register <whiteboard> in slides_xml_schema_definition.xml; remove it
  from the undefined element type list
- Update SKILL.md quick-reference table and按需再读 section to point to
  the new whiteboard reference
- Update xml-schema-quick-ref.md with <whiteboard> syntax examples
- Update slide create/get/replace references to include whiteboard as a
  valid <data> child element
- Tighten fallback_if_missing descriptions in planning-layer.md and
  asset-planning.md: replace "shapes" wording with neutral intent
  language and add "whiteboard diagrams" to the fallback tool lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): refine whiteboard reference doc structure and content

- Restructure doc: common attributes and prerequisites moved to top
- Move design quality rules under SVG mode section
- Add z-order inline note to full-screen layout example
- Replace JS coordinate script with Python, broaden scope to decorative elements
- Delete redundant Mermaid examples (keep one complete whiteboard+flowchart)
- Add prerequisite link and references section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): clarify chart vs whiteboard selection and fix doc gaps

- lark-slides-whiteboard: add chart vs whiteboard decision table at top;
  fix intro and SVG use-case list to remove bar/line (those belong to <chart>)
- SKILL.md: split whiteboard quick-ref row into chart row + whiteboard row;
  fix sidebar link label to match actual scope
- asset-planning: correct chart asset type — remove funnel/scatter (unsupported
  by <chart> XSD) and note they fall back to <whiteboard> SVG
- visual-planning: add one-line whiteboard preference hint to
  architecture-diagram and process-flow layout types
- validation-checklist: add Whiteboard Elements section noting slide.get
  does not return SVG/Mermaid content; content correctness requires manual
  visual sign-off

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): add SVG decorative visibility principles

Add two design rules to SVG quality requirements: check background
luminance before writing SVG (dark bg requires higher contrast), and
use non-linear brightness jumps (e.g. 0.10→0.40→0.70→1.0) instead of
linear opacity stacking (0.04→0.08→0.12) which produces near-identical
layers on dark backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): add custom icon use case to whiteboard SVG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): fix whiteboard SVG rendering rules

- content area is determined by child element bounding box union, not svg width/height/viewBox/xmlns
- viewBox only purpose: provide reference for percentage-based attribute values; omit when using absolute coords
- remove redundant attributes from all svg examples, use bare <svg> tags
- drop positive/negative coordinate guidance; rendering rule simplified to bounding-box auto-scale
2026-06-04 11:58:09 +08:00
suhui928
8ce38793a7 feat: add contact skill domain guidance (#1144)
* feat(lark-contact): route user_profiles batch_query in skill

- Add user_profiles batch_query row to the routing table.
- Add a worked example next to the search-user one, with `lark-cli
  schema` first (best practice: don't guess `--data` / `--params`).
- Trim description: drop the duplicated trigger clause, add
  personal_status / signature to the capability list so routing picks
  this skill up for those queries.
2026-06-03 22:32:27 +08:00
112 changed files with 49664 additions and 865 deletions

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
# it bans are still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/|shortcuts/mail/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -115,17 +115,17 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on drive ──
# ── legacy shared error helpers banned on migrated domains ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
# are invisible to the errs-typed-only ban above. Migrated domains use
# typed errs.* builders or domain-local file-I/O helpers instead; this
# prevents reintroduction while unmigrated domains continue to use the
# shared helpers until their later migration phase.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
shapes. Use typed errs.NewXxxError builders or a domain-local
file-I/O helper.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
@@ -1009,6 +1026,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45

View File

@@ -61,6 +61,8 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:

View File

@@ -244,6 +244,8 @@ func APIHint(subtype errs.Subtype) string {
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
var mailCodeMeta = map[int]CodeMeta{
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
}
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

View File

@@ -439,6 +439,11 @@
"final_score": "78.7030",
"recommend": "true"
},
{
"scope_name": "slides:presentation:screenshot",
"final_score": "78.7030",
"recommend": "true"
},
{
"scope_name": "slides:presentation:create",
"final_score": "79.4755",

View File

@@ -165,6 +165,10 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

View File

@@ -188,6 +188,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills ls -g",
},
{
name: "list global json",
run: func(u *Updater) *NpmResult {
return u.ListGlobalSkillsJSON()
},
want: "-y skills ls -g --json",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {

View File

@@ -4,6 +4,7 @@
package skillscheck
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -57,6 +58,28 @@ func ParseSkillsList(text string) []string {
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type globalSkill struct {
Name string `json:"name"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil
}
seen := map[string]bool{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
}
return sortedKeys(seen)
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -77,8 +100,11 @@ func parseGlobalSkillsList(lines []string) []string {
continue
}
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
if strings.HasPrefix(trimmed, "Agents:") {
continue
}
if isGlobalSkillsSectionHeader(trimmed) {
continue
}
@@ -91,21 +117,24 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0]
// Validate and add
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func isGlobalSkillsSectionHeader(line string) bool {
switch line {
case "General", "Project", "Local":
return true
default:
return false
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -195,6 +224,7 @@ func PlanSync(input SyncInput) SyncPlan {
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
@@ -239,10 +269,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 2: List local (installed) skills ---
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
// --- Step 3: Read previous state ---
@@ -298,6 +327,24 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
textResult := runner.ListGlobalSkills()
if textResult != nil && textResult.Err == nil {
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
return nil, false
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -67,6 +67,49 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
}
}
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
input := `Global Skills
General
lark-apps ~/.agents/skills/lark-apps
lark-base ~/.agents/skills/lark-base
`
got := ParseSkillsList(input)
want := []string{"lark-apps", "lark-base"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSON(t *testing.T) {
input := `[
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
{"name":""},
{"name":" "},
{"name":"bad skill"}
]`
got := ParseGlobalSkillsJSON(input)
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`{"name":"lark-calendar"}`,
`[]`,
} {
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -113,14 +156,18 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
officialOut string
globalJSONOut string
globalOut string
officialErr error
globalJSONErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
listedGlobalJSON int
listedGlobalText int
}
func officialSkillsOutput(names ...string) string {
@@ -146,6 +193,19 @@ func globalSkillsOutput(names ...string) string {
return b.String()
}
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
@@ -153,7 +213,16 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
return r
}
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
@@ -186,8 +255,9 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -199,6 +269,12 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState()
if err != nil || !readable {
@@ -262,47 +338,73 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
}
}
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
}
@@ -344,6 +446,7 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -375,6 +478,7 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
@@ -473,6 +577,7 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -497,6 +602,7 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -537,8 +643,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

@@ -16,6 +16,7 @@ import (
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/drive/",
"shortcuts/mail/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -17,6 +17,7 @@ import (
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/drive/",
"shortcuts/mail/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -894,27 +894,33 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"ResolveOpenIDs",
"HandleApiResult",
}
for _, helper := range helpers {
t.Run(helper, func(t *testing.T) {
src := `package drive
paths := []string{
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
}
for _, path := range paths {
for _, helper := range helpers {
t.Run(path+"_"+helper, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.47",
"version": "1.0.48",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -15,6 +15,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
)
@@ -57,6 +58,7 @@ type DriveMediaMultipartUploadConfig struct {
Reader io.Reader
}
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
@@ -98,6 +100,52 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
}
// UploadDriveMediaAllTyped is the typed-error counterpart of
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
// transport failures as typed network errors, and API failures are classified
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
fileReader = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatErrorTyped(err)
}
defer f.Close()
fileReader = f
}
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
fd.AddField("parent_type", cfg.ParentType)
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
if cfg.ParentNode != nil {
fd.AddField("parent_node", *cfg.ParentNode)
}
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
}
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
@@ -130,6 +178,43 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
}
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
// CallAPITyped, malformed session plans surface as invalid-response internal
// errors, and per-part transport/API failures are classified the same way as
// UploadDriveMediaAllTyped.
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
// string instead of relying on field omission like upload_all does.
prepareBody := map[string]interface{}{
"file_name": cfg.FileName,
"parent_type": cfg.ParentType,
"parent_node": cfg.ParentNode,
"size": cfg.FileSize,
}
if cfg.Extra != "" {
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
}
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
// The backend chooses both chunk size and chunk count. Validate them once so
// the streaming loop can follow the returned plan without re-checking shape.
@@ -280,3 +365,122 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
}
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
// message so callers see which upload step failed. Non-typed errors are
// returned unchanged.
func prefixDriveMediaUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
}
return err
}
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
// malformed plan as a typed invalid-response internal error.
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
session := DriveMediaMultipartUploadSession{
UploadID: GetString(data, "upload_id"),
BlockSize: int64(GetFloat(data, "block_size")),
BlockNum: int(GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
}
return session, nil
}
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
// with a typed invalid-response internal error for a missing file_token.
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
}
return fileToken, nil
}
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
// with typed errors for file-open, file-read, and per-part upload failures.
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
var r io.Reader
if cfg.Reader != nil {
r = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return WrapInputStatErrorTyped(err)
}
defer f.Close()
r = f
}
maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := cfg.FileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
if readErr != nil {
return WrapInputStatErrorTyped(readErr)
}
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
remaining -= int64(n)
}
return nil
}
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
}
return nil
}
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
}

View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_123"},
},
})
payload := []byte{0x89, 0x50, 0x4e, 0x47}
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
}
if fileToken != "file_typed_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123")
}
}
func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 999,
"msg": "upload rejected",
},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("category = %s, want api", p.Category)
}
if p.Code != 999 {
t.Fatalf("code = %d, want 999", p.Code)
}
if !strings.HasPrefix(p.Message, "upload media failed: ") || !strings.Contains(p.Message, "upload rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaAllTypedFileOpenFailure(t *testing.T) {
runtime, _ := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
FilePath: "missing.bin",
FileName: "missing.bin",
FileSize: 1,
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected typed validation error, got %T (%v)", err, err)
}
}
func TestUploadDriveMediaMultipartTypedBuildsPreparePartsAndFinish(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_typed_1",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_multi"},
},
})
payload := bytes.Repeat([]byte{0xCD}, int(size))
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
}
if fileToken != "file_typed_multi" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_multi")
}
}
func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
})
}
}
func TestUploadDriveMediaMultipartTypedPartAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI || p.Code != 999 {
t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code)
}
if !strings.HasPrefix(p.Message, "upload media part failed: ") || !strings.Contains(p.Message, "chunk rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
if !strings.Contains(p.Message, "upload media finish failed: no file_token returned") {
t.Fatalf("message = %q", p.Message)
}
}

View File

@@ -217,6 +217,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
return v
}
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
func (ctx *RuntimeContext) IntArray(name string) []int {
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -1286,6 +1292,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "int_array":
cmd.Flags().IntSlice(fl.Name, nil, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":

View File

@@ -4,9 +4,12 @@
package common
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -56,3 +59,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}
func TestShortcutFlagIntArray(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
var got []int
shortcut := Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "capture screenshots",
Flags: []Flag{
{Name: "slide-number", Type: "int_array"},
},
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
got = runtime.IntArray("slide-number")
return nil
},
}
shortcut.Mount(parent, f)
parent.SetArgs([]string{"+screenshot", "--slide-number", "1", "--slide-number", "2,3"})
if err := parent.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
t.Fatalf("slide-number = %#v, want %#v", got, want)
}
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var DriveCover = common.Shortcut{
Service: "drive",
Command: "+cover",
Description: "List or download stable cover presets for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "spec", Desc: "cover preset: default | icon | grid | small | middle | big | square"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list built-in cover specs without downloading"},
{Name: "output", Desc: "local output path for downloaded cover"},
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDrivePreviewMode(runtime.Str("spec"), runtime.Bool("list-only"), runtime.Str("output"), "spec"); err != nil {
return err
}
if err := validateDrivePreviewIfExists(runtime.Str("if-exists")); err != nil {
return err
}
if spec := strings.TrimSpace(runtime.Str("spec")); spec != "" {
if _, ok := findDriveCoverSpec(spec); !ok {
return wrapDriveCoverUnavailable(spec)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
if runtime.Bool("list-only") {
return common.NewDryRunAPI().
Desc("List built-in cover specs (no API call)").
Set("mode", "list").
Set("file_token", fileToken).
Set("candidates", buildDriveCoverListOutput(fileToken)["candidates"])
}
spec, _ := findDriveCoverSpec(runtime.Str("spec"))
params := buildDriveCoverDownloadParams(strings.TrimSpace(runtime.Str("version")), spec)
dry := common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download selected cover preset directly via preview_download").
Params(params).
Set("file_token", fileToken).
Set("selected_spec", spec.Name).
Set("output", runtime.Str("output"))
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedSpec := strings.TrimSpace(runtime.Str("spec"))
outputPath := runtime.Str("output")
ifExists := runtime.Str("if-exists")
if runtime.Bool("list-only") {
runtime.Out(buildDriveCoverListOutput(fileToken), nil)
return nil
}
spec, ok := findDriveCoverSpec(requestedSpec)
if !ok {
return wrapDriveCoverUnavailable(requestedSpec)
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover %s for file %s\n", spec.Name, common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, buildDriveCoverDownloadParams(version, spec), outputPath, ifExists, spec.FallbackExt)
if err != nil {
return wrapDriveCoverDownloadError(err, spec.Name)
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_spec"] = spec.Name
runtime.Out(result, nil)
return nil
},
}
// wrapDriveCoverDownloadError reclassifies preview_download HTTP 404 responses
// on the +cover path as a failed precondition on --spec, because the Drive
// shortcut contract documents 404 as "this file has no artifact for that cover
// preset" rather than a transient transport failure.
func wrapDriveCoverDownloadError(err error, requestedSpec string) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Code != http.StatusNotFound {
return err
}
hint := fmt.Sprintf(
"This may mean no artifact exists for --spec %q, or that the file token/version is invalid. Verify the inputs, or rerun with `lark-cli drive +cover --file-token <file-token> --list-only`. Available cover specs: %s",
requestedSpec,
strings.Join(availableDriveCoverSpecs(), ", "),
)
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"preview_download returned HTTP 404 for --spec %q",
requestedSpec,
).WithParam("--spec").WithCode(problem.Code).WithLogID(problem.LogID).WithHint(hint).WithCause(err)
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "List or download available preview artifacts for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDrivePreviewMode(runtime.Str("type"), runtime.Bool("list-only"), runtime.Str("output"), "type"); err != nil {
return err
}
return validateDrivePreviewIfExists(runtime.Str("if-exists"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
body := map[string]interface{}{}
if version != "" {
body["version"] = version
}
dry := common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/:file_token/preview_result").
Desc("[1] Fetch preview candidates for a Drive file").
Set("file_token", fileToken)
if len(body) > 0 {
dry.Body(body)
}
if runtime.Bool("list-only") {
return dry.Set("mode", "list")
}
downloadParams := map[string]interface{}{
"preview_type": "<selected type_code from preview_result>",
}
if version != "" {
downloadParams["version"] = version
} else {
downloadParams["version"] = "<resolved version from preview_result>"
}
return dry.
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", runtime.Str("type")).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
outputPath := runtime.Str("output")
ifExists := runtime.Str("if-exists")
body := map[string]interface{}{}
if version != "" {
body["version"] = version
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
return err
}
if runtime.Bool("list-only") {
runtime.Out(buildDrivePreviewListOutput(fileToken, candidates), nil)
return nil
}
candidate, ok := selectDrivePreviewCandidate(candidates, requestedType)
if !ok {
return wrapDrivePreviewUnavailable(fileToken, requestedType, candidates, "")
}
if !candidate.Downloadable {
return wrapDrivePreviewNotReady(fileToken, requestedType, candidate)
}
downloadVersion := version
if downloadVersion == "" {
downloadVersion = versionString(data["version"])
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading preview %s for file %s\n", candidate.Type, common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, candidate.TypeCode, downloadVersion, outputPath, ifExists, drivePreviewFallbackExt(candidate.Type))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = candidate.Type
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,813 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
)
type drivePreviewCandidate struct {
Type string
TypeCode string
TypeName string
Label string
Status string
StatusCode string
Downloadable bool
Reason string
}
type driveCoverSpec struct {
Name string
Label string
Description string
PreviewType string
BusType string
Platform string
Width int
Height int
Policy string
FallbackExt string
}
type driveExtensionResolution struct {
Ext string
Source string
Detail string
}
type drivePreviewTypeMeta struct {
Code string
Name string
Type string
Label string
Aliases []string
}
type drivePreviewStatusMeta struct {
Code string
Name string
Reason string
Downloadable bool
}
var drivePreviewMimeToExt = map[string]string{
"application/json": ".json",
"application/msword": ".doc",
"application/pdf": ".pdf",
"application/xml": ".xml",
"application/zip": ".zip",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
}
var drivePreviewTypes = []drivePreviewTypeMeta{
{Code: "0", Name: "PDF", Type: "pdf", Label: "PDF Preview"},
{Code: "1", Name: "PNG", Type: "png", Label: "PNG Preview", Aliases: []string{"image"}},
{Code: "2", Name: "PAGES", Type: "pages", Label: "Paged Preview"},
{Code: "3", Name: "VIDEO", Type: "video", Label: "Video Preview"},
{Code: "4", Name: "MP4_360P", Type: "mp4_360p", Label: "MP4 360P Preview"},
{Code: "5", Name: "MP4_480P", Type: "mp4_480p", Label: "MP4 480P Preview"},
{Code: "6", Name: "MP4_720P", Type: "mp4_720p", Label: "MP4 720P Preview"},
{Code: "7", Name: "JPG", Type: "jpg", Label: "JPG Preview", Aliases: []string{"image"}},
{Code: "8", Name: "HTML", Type: "html", Label: "HTML Preview"},
{Code: "9", Name: "PDF_LIN", Type: "pdf_lin", Label: "Linearized PDF Preview"},
{Code: "10", Name: "XOD", Type: "xod", Label: "XOD Preview"},
{Code: "11", Name: "JPG_LIN", Type: "jpg_lin", Label: "Linearized JPG Preview", Aliases: []string{"image"}},
{Code: "12", Name: "PNG_LIN", Type: "png_lin", Label: "Linearized PNG Preview", Aliases: []string{"image"}},
{Code: "13", Name: "ARCHIVE", Type: "archive", Label: "Archive Preview"},
{Code: "14", Name: "TEXT", Type: "text", Label: "Text Preview"},
{Code: "15", Name: "PDF_PART", Type: "pdf_part", Label: "Partial PDF Preview"},
{Code: "16", Name: "SOURCE_FILE", Type: "source_file", Label: "Source File", Aliases: []string{"source"}},
{Code: "17", Name: "VIDEO_META", Type: "video_meta", Label: "Video Metadata"},
{Code: "18", Name: "WPS", Type: "wps", Label: "WPS Preview"},
{Code: "19", Name: "SPLIT_PNG", Type: "split_png", Label: "Split PNG Preview", Aliases: []string{"image"}},
{Code: "20", Name: "MEDIA_RESULT", Type: "media_result", Label: "Media Result"},
{Code: "21", Name: "MIME", Type: "mime", Label: "MIME Type"},
{Code: "22", Name: "SPILT_IMG_TXT", Type: "spilt_img_txt", Label: "Split Image Text"},
{Code: "23", Name: "MP4_1080P", Type: "mp4_1080p", Label: "MP4 1080P Preview"},
{Code: "24", Name: "IMAGE_META", Type: "image_meta", Label: "Image Metadata"},
{Code: "25", Name: "DOC_PART", Type: "doc_part", Label: "Document Part"},
{Code: "26", Name: "WATERMARK_PDF", Type: "watermark_pdf", Label: "Watermarked PDF Preview"},
{Code: "27", Name: "FILE_WATERMARK", Type: "file_watermark", Label: "File Watermark"},
}
var drivePreviewStatuses = []drivePreviewStatusMeta{
{Code: "0", Name: "READY", Downloadable: true},
{Code: "1", Name: "PROCESSING", Reason: "Preview is still processing."},
{Code: "2", Name: "FAILED", Reason: "Preview generation failed."},
{Code: "3", Name: "FAILED_NOT_RETRY", Reason: "Preview generation failed and will not retry."},
{Code: "4", Name: "INVALID_EXTENTION", Reason: "File extension is invalid for this preview type."},
{Code: "5", Name: "FILE_TOO_LARGE", Reason: "File is too large for preview generation."},
{Code: "6", Name: "EMPTY_FILE", Reason: "File is empty."},
{Code: "7", Name: "NO_SUPPORT", Reason: "Preview is not supported for this file."},
{Code: "8", Name: "INVALID_PREVIEW_TYPE", Reason: "Preview type is invalid."},
{Code: "9", Name: "NEED_PASSWORD", Reason: "Preview requires a password."},
{Code: "10", Name: "FILE_INVALID", Reason: "File is invalid."},
{Code: "11", Name: "TOO_MANY_PAGES", Reason: "File has too many pages for preview."},
{Code: "1001", Name: "ARCHIVE_INVALID_FORMAT", Reason: "Archive format is invalid."},
{Code: "1002", Name: "ARCHIVE_TOO_MANY_NODES", Reason: "Archive contains too many nodes."},
{Code: "1003", Name: "ARCHIVE_TOO_MANY_NODES_PER_DIR", Reason: "Archive directory contains too many nodes."},
{Code: "1004", Name: "THIRD_ENC_NO_PERMISSION", Reason: "No permission for third-party encrypted file."},
{Code: "1006", Name: "NOT_SUPPORT_DECRYPT_THIRD_ENC_FILE", Reason: "Third-party encrypted file cannot be decrypted for preview."},
}
var drivePreviewTypeByCode = func() map[string]drivePreviewTypeMeta {
out := make(map[string]drivePreviewTypeMeta, len(drivePreviewTypes))
for _, meta := range drivePreviewTypes {
out[meta.Code] = meta
}
return out
}()
var drivePreviewStatusByCode = func() map[string]drivePreviewStatusMeta {
out := make(map[string]drivePreviewStatusMeta, len(drivePreviewStatuses))
for _, meta := range drivePreviewStatuses {
out[meta.Code] = meta
}
return out
}()
var driveCoverSpecs = []driveCoverSpec{
{
Name: "default",
Label: "Default Cover",
Description: "Standard large cover (1280x1280).",
PreviewType: "1",
BusType: "cover",
Platform: "pc",
FallbackExt: ".png",
},
{
Name: "icon",
Label: "Icon",
Description: "Small list icon (120x120).",
PreviewType: "1",
BusType: "icon",
FallbackExt: ".png",
},
{
Name: "grid",
Label: "Grid Cover",
Description: "Grid/card stream cover (360x360).",
PreviewType: "1",
BusType: "grid",
FallbackExt: ".png",
},
{
Name: "small",
Label: "Small Graph",
Description: "PC small graph cover (480x480).",
PreviewType: "1",
BusType: "small_graph",
Platform: "pc",
FallbackExt: ".png",
},
{
Name: "middle",
Label: "Middle Cover",
Description: "Medium-sized cover (720x720).",
PreviewType: "1",
BusType: "middle",
FallbackExt: ".png",
},
{
Name: "big",
Label: "Big Cover",
Description: "Large mobile-oriented cover (850x850).",
PreviewType: "1",
BusType: "big",
Platform: "mobile",
FallbackExt: ".png",
},
{
Name: "square",
Label: "Square Cover",
Description: "Square-cropped grid cover (360x360).",
PreviewType: "1",
Width: 360,
Height: 360,
Policy: "near",
FallbackExt: ".png",
},
}
// validateDrivePreviewMode checks the required flag combinations for list and
// download modes.
func validateDrivePreviewMode(selected string, listOnly bool, outputPath, flagName string) error {
selected = strings.TrimSpace(selected)
outputPath = strings.TrimSpace(outputPath)
selectedFlag := "--" + flagName
if listOnly {
if selected != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be combined with --list-only", selectedFlag).WithParam(selectedFlag)
}
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --list-only").WithParam("--output")
}
return nil
}
if selected == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "either --list-only or %s is required", selectedFlag).WithParam(selectedFlag)
}
if outputPath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output is required when %s is set", selectedFlag).WithParam("--output")
}
return nil
}
// validateDrivePreviewIfExists validates the accepted overwrite policy values.
func validateDrivePreviewIfExists(policy string) error {
switch strings.TrimSpace(policy) {
case "", drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename:
return nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", policy).WithParam("--if-exists")
}
}
// fetchDrivePreviewCandidates loads preview_result data and normalizes the
// returned candidate list.
func fetchDrivePreviewCandidates(runtime *common.RuntimeContext, fileToken string, body map[string]interface{}) (map[string]interface{}, []drivePreviewCandidate, error) {
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_result", validate.EncodePathSegment(fileToken)),
nil,
body,
)
if err != nil {
return nil, nil, err
}
return data, normalizeDrivePreviewCandidates(data), nil
}
// normalizeDrivePreviewCandidates converts preview_result items into internal
// candidate records with stable type and status metadata.
func normalizeDrivePreviewCandidates(data map[string]interface{}) []drivePreviewCandidate {
items := common.GetSlice(data, "preview_results")
candidates := make([]drivePreviewCandidate, 0, len(items))
for _, item := range items {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
typeCode := firstString(raw, "preview_type", "type_code", "type")
statusCode := firstString(raw, "preview_status", "status_code", "status")
candidate := drivePreviewCandidate{
TypeCode: typeCode,
StatusCode: statusCode,
Reason: strings.TrimSpace(firstString(raw, "reason", "status_msg", "message", "msg", "detail")),
}
applyDrivePreviewTypeMeta(&candidate)
applyDrivePreviewStatusMeta(&candidate)
candidates = append(candidates, candidate)
}
return candidates
}
// selectDrivePreviewCandidate matches a requested preview type or alias against
// the available candidates.
func selectDrivePreviewCandidate(candidates []drivePreviewCandidate, requested string) (drivePreviewCandidate, bool) {
requested = normalizeDrivePreviewRequest(requested)
if requested == "" {
return drivePreviewCandidate{}, false
}
for _, candidate := range candidates {
if requested == candidate.Type || requested == strings.ToLower(candidate.TypeName) || requested == strings.ToLower(strings.TrimSpace(candidate.TypeCode)) {
return candidate, true
}
}
var firstAliasMatch drivePreviewCandidate
hasAliasMatch := false
for _, candidate := range candidates {
if !slices.Contains(previewAliasesForCandidate(candidate), requested) {
continue
}
if candidate.Downloadable {
return candidate, true
}
if !hasAliasMatch {
firstAliasMatch = candidate
hasAliasMatch = true
}
}
if hasAliasMatch {
return firstAliasMatch, true
}
return drivePreviewCandidate{}, false
}
// buildDrivePreviewListOutput formats preview candidates for --list-only
// responses.
func buildDrivePreviewListOutput(fileToken string, candidates []drivePreviewCandidate) map[string]interface{} {
items := make([]map[string]interface{}, 0, len(candidates))
for _, candidate := range candidates {
item := map[string]interface{}{
"type": candidate.Type,
"type_code": candidate.TypeCode,
"label": candidate.Label,
"status": candidate.Status,
"status_code": candidate.StatusCode,
"downloadable": candidate.Downloadable,
}
if candidate.Reason != "" {
item["reason"] = candidate.Reason
}
items = append(items, item)
}
out := map[string]interface{}{
"mode": "list",
"file_token": fileToken,
"candidates": items,
}
if len(items) > 0 {
out["next_action"] = "select one candidate and rerun with --type plus --output"
}
return out
}
// buildDriveCoverListOutput formats the built-in cover specs for --list-only
// responses.
func buildDriveCoverListOutput(fileToken string) map[string]interface{} {
items := make([]map[string]interface{}, 0, len(driveCoverSpecs))
for _, spec := range driveCoverSpecs {
item := map[string]interface{}{
"spec": spec.Name,
"label": spec.Label,
}
if spec.Description != "" {
item["description"] = spec.Description
}
items = append(items, item)
}
return map[string]interface{}{
"mode": "list",
"file_token": fileToken,
"candidates": items,
"next_action": "select one spec and rerun with --spec plus --output",
}
}
// findDriveCoverSpec resolves a cover spec by its user-facing name.
func findDriveCoverSpec(name string) (driveCoverSpec, bool) {
name = strings.ToLower(strings.TrimSpace(name))
for _, spec := range driveCoverSpecs {
if spec.Name == name {
return spec, true
}
}
return driveCoverSpec{}, false
}
// buildDriveCoverDownloadParams translates a cover spec into preview_download
// query parameters.
func buildDriveCoverDownloadParams(version string, spec driveCoverSpec) map[string]interface{} {
params := map[string]interface{}{
"preview_type": spec.PreviewType,
}
if strings.TrimSpace(spec.BusType) != "" {
params["bus_type"] = spec.BusType
}
if strings.TrimSpace(spec.Platform) != "" {
params["platform"] = spec.Platform
}
if spec.Width > 0 {
params["width"] = spec.Width
}
if spec.Height > 0 {
params["height"] = spec.Height
}
if strings.TrimSpace(spec.Policy) != "" {
params["policy"] = spec.Policy
}
if strings.TrimSpace(version) != "" {
params["version"] = version
}
return params
}
// downloadDrivePreviewArtifact downloads a preview artifact for a single
// preview_type value.
func downloadDrivePreviewArtifact(ctx context.Context, runtime *common.RuntimeContext, fileToken, previewType, version, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
query := map[string]interface{}{
"preview_type": previewType,
}
if strings.TrimSpace(version) != "" {
query["version"] = version
}
return downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, query, outputPath, ifExists, fallbackExt)
}
// downloadDrivePreviewArtifactWithParams downloads a preview artifact using the
// provided preview_download query parameters and writes it to the local path.
func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common.RuntimeContext, fileToken string, query map[string]interface{}, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
queryParams := make(larkcore.QueryParams, len(query))
for key, value := range query {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" {
continue
}
queryParams[key] = []string{text}
}
apiReq := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: queryParams,
}
resp, err := runtime.DoAPIStream(ctx, apiReq)
if err != nil {
return nil, wrapDriveNetworkErr(err, "preview download failed: %s", err)
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
if err != nil {
return nil, err
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
}
return map[string]interface{}{
"output_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
"status": "READY",
}, nil
}
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
switch ifExists {
case "", drivePreviewIfExistsError:
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --if-exists overwrite or rename)", finalPath).WithParam("--output")
} else if !errors.Is(statErr, fs.ErrNotExist) {
return "", nil, errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", finalPath, statErr).WithCause(statErr)
}
return finalPath, resolution, nil
case drivePreviewIfExistsOverwrite:
return finalPath, resolution, nil
case drivePreviewIfExistsRename:
renamed, err := nextAvailableDrivePreviewPath(runtime.FileIO(), finalPath)
if err != nil {
return "", nil, err
}
if _, err := runtime.ResolveSavePath(renamed); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
return renamed, resolution, nil
default:
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", ifExists).WithParam("--if-exists")
}
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
if _, err := fio.Stat(path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return path, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", path, err).WithCause(err)
}
dir := filepath.Dir(path)
ext := filepath.Ext(path)
base := strings.TrimSuffix(filepath.Base(path), ext)
for i := 1; i < 10000; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
if _, err := fio.Stat(candidate); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return candidate, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access candidate output path %s: %s", candidate, err).WithCause(err)
}
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot allocate a unique output path for %s", path)
}
// autoAppendDrivePreviewExtension appends an inferred extension when the user
// did not provide one explicitly.
func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fallbackExt string) (string, *driveExtensionResolution) {
if drivePreviewHasExplicitExtension(outputPath) {
return outputPath, nil
}
normalizedPath := outputPath
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if fallbackExt != "" {
return normalizedPath + fallbackExt, &driveExtensionResolution{
Ext: fallbackExt,
Source: "fallback",
Detail: "default fallback",
}
}
return outputPath, nil
}
// drivePreviewHasExplicitExtension reports whether the path already ends with a
// usable filename extension.
func drivePreviewHasExplicitExtension(path string) bool {
ext := filepath.Ext(path)
return ext != "" && ext != "."
}
// drivePreviewExtensionByContentType maps a response Content-Type header to a
// file extension when possible.
func drivePreviewExtensionByContentType(contentType string) *driveExtensionResolution {
if contentType == "" {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := drivePreviewMimeToExt[strings.ToLower(mediaType)]; ok && ext != "" {
return &driveExtensionResolution{
Ext: ext,
Source: "Content-Type",
Detail: contentType,
}
}
return nil
}
// drivePreviewExtensionByContentDisposition extracts an extension from the
// response filename metadata.
func drivePreviewExtensionByContentDisposition(header http.Header) *driveExtensionResolution {
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
if filename == "" {
return nil
}
ext := filepath.Ext(filename)
if ext == "" || ext == "." {
return nil
}
return &driveExtensionResolution{
Ext: ext,
Source: "Content-Disposition",
Detail: filename,
}
}
// drivePreviewFallbackExt returns the default extension for known preview type
// aliases when headers do not provide one.
func drivePreviewFallbackExt(alias string) string {
switch normalizeDrivePreviewRequest(alias) {
case "pdf":
return ".pdf"
case "html":
return ".html"
case "text":
return ".txt"
case "png", "png_lin", "split_png":
return ".png"
case "jpg", "jpg_lin":
return ".jpg"
case "source", "source_file":
return ""
default:
return ""
}
}
// applyDrivePreviewTypeMeta fills normalized type metadata from the preview
// type code.
func applyDrivePreviewTypeMeta(candidate *drivePreviewCandidate) {
if candidate == nil {
return
}
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
candidate.Type = meta.Type
candidate.TypeName = meta.Name
candidate.Label = meta.Label
return
}
code := strings.TrimSpace(candidate.TypeCode)
if code == "" {
candidate.Type = "unknown"
candidate.TypeName = "UNKNOWN"
candidate.Label = "Unknown Preview Type"
return
}
candidate.Type = "unknown_" + code
candidate.TypeName = "UNKNOWN"
candidate.Label = fmt.Sprintf("Unknown Preview Type %s", code)
}
// applyDrivePreviewStatusMeta fills normalized status metadata from the preview
// status code.
func applyDrivePreviewStatusMeta(candidate *drivePreviewCandidate) {
if candidate == nil {
return
}
if meta, ok := drivePreviewStatusByCode[candidate.StatusCode]; ok {
candidate.Status = meta.Name
candidate.Downloadable = meta.Downloadable
if candidate.Reason == "" && !meta.Downloadable {
candidate.Reason = meta.Reason
}
if meta.Downloadable {
candidate.Reason = ""
}
return
}
candidate.Status = "UNKNOWN"
candidate.Downloadable = false
if candidate.Reason == "" {
if strings.TrimSpace(candidate.StatusCode) == "" {
candidate.Reason = "Preview status is missing."
} else {
candidate.Reason = fmt.Sprintf("Unknown preview status %s.", candidate.StatusCode)
}
}
}
// normalizeDrivePreviewRequest canonicalizes user input for preview type
// matching.
func normalizeDrivePreviewRequest(requested string) string {
requested = strings.ToLower(strings.TrimSpace(requested))
requested = strings.ReplaceAll(requested, "-", "_")
requested = strings.ReplaceAll(requested, " ", "_")
return requested
}
// previewAliasesForCandidate returns configured aliases for a preview
// candidate's type code.
func previewAliasesForCandidate(candidate drivePreviewCandidate) []string {
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
return meta.Aliases
}
return nil
}
// firstString returns the first non-empty string-like value from the provided
// keys.
func firstString(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
return t
}
case fmt.Stringer:
if s := strings.TrimSpace(t.String()); s != "" {
return s
}
case float64:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case bool:
return strconv.FormatBool(t)
}
}
return ""
}
// versionString normalizes version fields from heterogeneous API payload types.
func versionString(v interface{}) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
default:
return ""
}
}
// availableDrivePreviewTypes lists unique normalized preview type names from
// the candidate set.
func availableDrivePreviewTypes(candidates []drivePreviewCandidate) []string {
seen := map[string]bool{}
out := make([]string, 0, len(candidates))
for _, candidate := range candidates {
name := strings.TrimSpace(candidate.Type)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// availableDriveCoverSpecs lists the supported built-in cover spec names.
func availableDriveCoverSpecs() []string {
out := make([]string, 0, len(driveCoverSpecs))
for _, spec := range driveCoverSpecs {
out = append(out, spec.Name)
}
return out
}
// wrapDrivePreviewUnavailable builds a validation error for an unsupported
// preview selection.
func wrapDrivePreviewUnavailable(fileToken, requested string, candidates []drivePreviewCandidate, reason string) error {
available := availableDrivePreviewTypes(candidates)
if reason == "" {
reason = fmt.Sprintf("requested preview type %q is not available for file %s", requested, fileToken)
}
hint := "rerun with --list-only to inspect available preview types"
if len(available) > 0 {
hint = fmt.Sprintf("available preview types: %s", strings.Join(available, ", "))
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// wrapDrivePreviewNotReady builds an actionable error for a preview candidate
// that exists but is not yet downloadable.
func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePreviewCandidate) error {
reason := candidate.Reason
if reason == "" {
reason = fmt.Sprintf("preview type %q is not downloadable yet (status=%s)", requested, candidate.Status)
}
hint := fmt.Sprintf("rerun `lark-cli drive +preview --file-token %s --list-only` to inspect current candidate status", fileToken)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --spec %q", requested).
WithHint("available cover specs: %s", strings.Join(availableDriveCoverSpecs(), ", ")).
WithParam("--spec")
}

View File

@@ -0,0 +1,926 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestDrivePreviewListOnlyNormalizesCandidates verifies list mode output is
// normalized from preview_result payloads.
func TestDrivePreviewListOnlyNormalizesCandidates(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 0, "preview_status": 0},
{"preview_type": 14, "preview_status": 1},
{"preview_type": 16, "preview_status": 7},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--list-only",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["mode"]; got != "list" {
t.Fatalf("mode=%v, want list", got)
}
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != 3 {
t.Fatalf("len(candidates)=%d, want 3", len(candidates))
}
first, _ := candidates[0].(map[string]interface{})
if got := first["type"]; got != "pdf" {
t.Fatalf("candidate[0].type=%v, want pdf", got)
}
if got := first["type_code"]; got != "0" {
t.Fatalf("candidate[0].type_code=%v, want 0", got)
}
if got := first["status"]; got != "READY" {
t.Fatalf("candidate[0].status=%v, want READY", got)
}
if got := first["downloadable"]; got != true {
t.Fatalf("candidate[0].downloadable=%v, want true", got)
}
second, _ := candidates[1].(map[string]interface{})
if got := second["status_code"]; got != "1" {
t.Fatalf("candidate[1].status_code=%v, want 1", got)
}
if got := second["reason"]; got != "Preview is still processing." {
t.Fatalf("candidate[1].reason=%v, want processing reason", got)
}
}
// TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy verifies preview
// downloads use the resolved type and rename collision handling.
func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": 7,
"preview_results": []map[string]interface{}{
{"preview_type": 0, "preview_status": 0},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_preview/preview_download?preview_type=0",
Status: 200,
Body: []byte("%PDF-1.7"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--output", "report",
"--if-exists", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["selected_type"]; got != "pdf" {
t.Fatalf("selected_type=%v, want pdf", got)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "report (1).pdf")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview artifact at %q: %v", wantPath, err)
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 8, "preview_status": 0},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--output", "report",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected unavailable type error, got nil")
}
if !strings.Contains(err.Error(), `requested preview type "pdf" is not available`) {
t.Fatalf("unexpected error: %v", err)
}
}
// TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch verifies alias
// selection prefers a downloadable candidate over an earlier unavailable one.
func TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch(t *testing.T) {
candidate, ok := selectDrivePreviewCandidate([]drivePreviewCandidate{
{Type: "png", TypeCode: "1", Downloadable: false, Status: "PROCESSING"},
{Type: "jpg", TypeCode: "7", Downloadable: true, Status: "READY"},
}, "image")
if !ok {
t.Fatal("expected alias match, got none")
}
if candidate.Type != "jpg" {
t.Fatalf("selected candidate=%q, want jpg", candidate.Type)
}
if !candidate.Downloadable {
t.Fatalf("selected candidate should be downloadable: %+v", candidate)
}
}
// TestDriveCoverListOnlyUsesStaticSpecs verifies cover list mode returns the
// built-in spec catalog without calling APIs.
func TestDriveCoverListOnlyUsesStaticSpecs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--list-only",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != len(driveCoverSpecs) {
t.Fatalf("len(candidates)=%d, want %d", len(candidates), len(driveCoverSpecs))
}
last, _ := candidates[len(candidates)-1].(map[string]interface{})
if got := last["spec"]; got != "square" {
t.Fatalf("last spec=%v, want square", got)
}
}
// TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType verifies cover
// downloads send the expected preview_download query mapping.
func TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
Status: 200,
Body: []byte("png-data"),
Headers: http.Header{
"Content-Type": []string{"image/png"},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "square",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["selected_spec"]; got != "square" {
t.Fatalf("selected_spec=%v, want square", got)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "cover.png")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected cover file at %q: %v", wantPath, err)
}
if got := capturedQuery.Get("preview_type"); got != "1" {
t.Fatalf("preview_type=%q, want 1", got)
}
if got := capturedQuery.Get("bus_type"); got != "" {
t.Fatalf("bus_type=%q, want empty for square crop flow", got)
}
if got := capturedQuery.Get("platform"); got != "" {
t.Fatalf("platform=%q, want empty when using default platform", got)
}
if got := capturedQuery.Get("width"); got != "360" {
t.Fatalf("width=%q, want 360", got)
}
if got := capturedQuery.Get("height"); got != "360" {
t.Fatalf("height=%q, want 360", got)
}
if got := capturedQuery.Get("policy"); got != "near" {
t.Fatalf("policy=%q, want near", got)
}
}
// TestDriveCoverDownload404ReturnsFailedPrecondition verifies the +cover path
// reclassifies preview_download HTTP 404 as a non-retryable spec/state issue.
func TestDriveCoverDownload404ReturnsFailedPrecondition(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
Status: http.StatusNotFound,
Body: []byte(`{"code":404,"msg":"no artifact"}`),
Headers: http.Header{
"Content-Type": []string{"application/json"},
},
})
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "square",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected cover 404 error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if validationErr.Param != "--spec" {
t.Fatalf("param=%q, want --spec", validationErr.Param)
}
if validationErr.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", validationErr.Code, http.StatusNotFound)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want --list-only guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "file token/version is invalid") {
t.Fatalf("hint=%q, want invalid file token/version guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "available cover specs") && !strings.Contains(validationErr.Hint, "default, icon, grid") {
t.Fatalf("hint=%q, want available cover specs guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Error(), `preview_download returned HTTP 404 for --spec "square"`) {
t.Fatalf("message=%q, want neutral 404 message", validationErr.Error())
}
}
// newDrivePreviewRuntime builds a shortcut runtime with preconfigured preview
// and cover flags for DryRun and helper tests.
func newDrivePreviewRuntime(t *testing.T, use string, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: use}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("spec", "", "")
cmd.Flags().String("version", "", "")
cmd.Flags().String("output", "", "")
cmd.Flags().String("if-exists", drivePreviewIfExistsError, "")
cmd.Flags().Bool("list-only", false, "")
for name, value := range stringFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
for name, value := range boolFlags {
if !value {
continue
}
if err := cmd.Flags().Set(name, "true"); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig())
}
// decodeDryRunOutput marshals a DryRunAPI helper into a generic map for test
// assertions.
func decodeDryRunOutput(t *testing.T, dry *common.DryRunAPI) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return out
}
// TestDrivePreviewDryRunIncludesVersionAndMode verifies preview DryRun records
// versioned request metadata in download mode.
func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
"type": "image",
"version": "7",
"output": "preview",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "image" {
t.Fatalf("requested_type=%v, want image", got)
}
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "POST" {
t.Fatalf("method=%v, want POST", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
body, _ := call["body"].(map[string]interface{})
if got := body["version"]; got != "7" {
t.Fatalf("body.version=%v, want 7", got)
}
downloadCall, _ := api[1].(map[string]interface{})
if got := downloadCall["method"]; got != "GET" {
t.Fatalf("download method=%v, want GET", got)
}
if got := downloadCall["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_download" {
t.Fatalf("download url=%v, want preview_download", got)
}
params, _ := downloadCall["params"].(map[string]interface{})
if got := params["preview_type"]; got != "<selected type_code from preview_result>" {
t.Fatalf("download params.preview_type=%v, want placeholder", got)
}
if got := params["version"]; got != "7" {
t.Fatalf("download params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
}, map[string]bool{"list-only": true})
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "list" {
t.Fatalf("mode=%v, want list", got)
}
api, _ := data["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
if _, ok := call["body"]; ok {
t.Fatalf("dry-run body should be omitted when version is empty: %#v", call)
}
}
// TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion verifies
// download-mode DryRun documents the second preview_download step even when the
// final version is only known after preview_result resolves candidates.
func TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
"type": "pdf",
"output": "preview",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
downloadCall, _ := api[1].(map[string]interface{})
params, _ := downloadCall["params"].(map[string]interface{})
if got := params["version"]; got != "<resolved version from preview_result>" {
t.Fatalf("download params.version=%v, want resolved-version placeholder", got)
}
}
// TestDriveCoverDryRunListAndDownload verifies cover DryRun output for both
// list and download modes.
func TestDriveCoverDryRunListAndDownload(t *testing.T) {
listRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
}, map[string]bool{"list-only": true})
listData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), listRuntime))
if got := listData["mode"]; got != "list" {
t.Fatalf("list mode=%v, want list", got)
}
if _, ok := listData["candidates"].([]interface{}); !ok {
t.Fatalf("list candidates missing: %#v", listData)
}
downloadRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
"spec": "square",
"version": "3",
"output": "cover",
}, nil)
downloadData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), downloadRuntime))
if got := downloadData["selected_spec"]; got != "square" {
t.Fatalf("selected_spec=%v, want square", got)
}
api, _ := downloadData["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
params, _ := call["params"].(map[string]interface{})
if got := params["width"]; got != float64(360) {
t.Fatalf("params.width=%v, want 360", got)
}
if got := params["policy"]; got != "near" {
t.Fatalf("params.policy=%v, want near", got)
}
}
// TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform verifies DryRun
// params include version and built-in platform metadata for default covers.
func TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
"spec": "default",
"version": "5",
"output": "cover",
}, nil)
data := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
params, _ := call["params"].(map[string]interface{})
if got := params["bus_type"]; got != "cover" {
t.Fatalf("params.bus_type=%v, want cover", got)
}
if got := params["platform"]; got != "pc" {
t.Fatalf("params.platform=%v, want pc", got)
}
if got := params["version"]; got != "5" {
t.Fatalf("params.version=%v, want 5", got)
}
}
// TestDrivePreviewValidationErrors verifies preview flag validation rejects
// incomplete and conflicting argument combinations.
func TestDrivePreviewValidationErrors(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "either --list-only or --type is required") {
t.Fatalf("unexpected missing type error: %v", err)
}
err = mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--list-only",
"--type", "pdf",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--type cannot be combined with --list-only") {
t.Fatalf("unexpected list-only conflict: %v", err)
}
err = mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--output is required when --type is set") {
t.Fatalf("unexpected missing output error: %v", err)
}
}
// TestDrivePreviewNotReadyReturnsFailedPrecondition verifies a known but
// unready preview candidate returns a failed-precondition error.
func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 1, "preview_status": 1},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "image",
"--output", "preview",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected not-ready error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if validationErr.Param != "--type" {
t.Fatalf("param=%q, want --type", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "poster",
"--output", "cover",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected invalid spec error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--spec" {
t.Fatalf("param=%q, want --spec", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "available cover specs") {
t.Fatalf("hint=%q, want available specs", validationErr.Hint)
}
}
// TestDriveCoverValidationErrors verifies cover flag validation rejects
// incomplete and conflicting argument combinations.
func TestDriveCoverValidationErrors(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "default",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--output is required when --spec is set") {
t.Fatalf("unexpected missing output error: %v", err)
}
err = mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--list-only",
"--spec", "default",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--spec cannot be combined with --list-only") {
t.Fatalf("unexpected list-only conflict: %v", err)
}
}
// TestDrivePreviewCommonHelpers exercises helper branches for extension
// inference and fallback extension mapping.
func TestDrivePreviewCommonHelpers(t *testing.T) {
if got := drivePreviewFallbackExt("pdf"); got != ".pdf" {
t.Fatalf("fallbackExt(pdf)=%q, want .pdf", got)
}
if got := drivePreviewFallbackExt("html"); got != ".html" {
t.Fatalf("fallbackExt(html)=%q, want .html", got)
}
if got := drivePreviewFallbackExt("text"); got != ".txt" {
t.Fatalf("fallbackExt(text)=%q, want .txt", got)
}
if got := drivePreviewFallbackExt("jpg"); got != ".jpg" {
t.Fatalf("fallbackExt(jpg)=%q, want .jpg", got)
}
if got := drivePreviewFallbackExt("jpg_lin"); got != ".jpg" {
t.Fatalf("fallbackExt(jpg_lin)=%q, want .jpg", got)
}
if got := drivePreviewFallbackExt("split_png"); got != ".png" {
t.Fatalf("fallbackExt(split_png)=%q, want .png", got)
}
if got := drivePreviewFallbackExt("source"); got != "" {
t.Fatalf("fallbackExt(source)=%q, want empty", got)
}
if got := drivePreviewFallbackExt("unknown"); got != "" {
t.Fatalf("fallbackExt(unknown)=%q, want empty", got)
}
specs := availableDriveCoverSpecs()
if len(specs) == 0 || specs[len(specs)-1] != "square" {
t.Fatalf("availableDriveCoverSpecs()=%v, want square included", specs)
}
header := http.Header{}
header.Set("Content-Disposition", `attachment; filename="preview.pdf"`)
resolution := drivePreviewExtensionByContentDisposition(header)
if resolution == nil || resolution.Ext != ".pdf" {
t.Fatalf("content disposition resolution=%+v, want .pdf", resolution)
}
header.Set("Content-Disposition", `attachment; filename="preview"`)
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
t.Fatalf("content disposition without ext should be nil: %+v", resolution)
}
path, fallback := autoAppendDrivePreviewExtension("cover", http.Header{}, ".png")
if path != "cover.png" || fallback == nil || fallback.Source != "fallback" {
t.Fatalf("fallback append = (%q, %+v), want cover.png with fallback source", path, fallback)
}
path, fallback = autoAppendDrivePreviewExtension("cover.", http.Header{}, ".png")
if path != "cover.png" || fallback == nil {
t.Fatalf("trailing-dot append = (%q, %+v), want cover.png", path, fallback)
}
path, fallback = autoAppendDrivePreviewExtension("cover.pdf", http.Header{}, ".png")
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
// and output path resolution helpers across rename and overwrite flows.
func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
candidate := drivePreviewCandidate{TypeCode: "999", StatusCode: "", Reason: ""}
applyDrivePreviewTypeMeta(&candidate)
applyDrivePreviewStatusMeta(&candidate)
if candidate.Type != "unknown_999" {
t.Fatalf("candidate.Type=%q, want unknown_999", candidate.Type)
}
if candidate.Reason != "Preview status is missing." {
t.Fatalf("candidate.Reason=%q, want missing-status reason", candidate.Reason)
}
ready := drivePreviewCandidate{TypeCode: "1", StatusCode: "0"}
applyDrivePreviewTypeMeta(&ready)
applyDrivePreviewStatusMeta(&ready)
if ready.Type != "png" || !ready.Downloadable {
t.Fatalf("ready candidate=%+v, want downloadable png", ready)
}
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "preview.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
if !strings.HasSuffix(renamed, "preview (1).pdf") {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--if-exists" {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
}
if unusedPath != "fresh.pdf" {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
if !strings.HasSuffix(overwritten, "preview.pdf") {
t.Fatalf("overwritten=%q, want preview.pdf suffix", overwritten)
}
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype=%q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
}
type drivePreviewTestStringer string
type statErrorProvider struct {
inner fileio.Provider
err error
}
func (p *statErrorProvider) Name() string { return "stat-error" }
func (p *statErrorProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
return &statErrorFileIO{inner: p.inner.ResolveFileIO(ctx), err: p.err}
}
type statErrorFileIO struct {
inner fileio.FileIO
err error
}
func (f *statErrorFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) }
func (f *statErrorFileIO) Stat(string) (fileio.FileInfo, error) { return nil, f.err }
func (f *statErrorFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) }
func (f *statErrorFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
return f.inner.Save(path, opts, body)
}
// String implements fmt.Stringer for scalar helper tests.
func (s drivePreviewTestStringer) String() string { return string(s) }
// TestDrivePreviewScalarHelpers verifies scalar coercion helpers normalize
// mixed API field types into strings.
func TestDrivePreviewScalarHelpers(t *testing.T) {
got := firstString(map[string]interface{}{
"blank": " ",
"number": float64(7),
"flag": true,
"named": drivePreviewTestStringer(" named "),
"integer": int64(9),
}, "blank", "named", "number")
if got != "named" {
t.Fatalf("firstString()=%q, want named", got)
}
if got := firstString(map[string]interface{}{"flag": true}, "flag"); got != "true" {
t.Fatalf("firstString(bool)=%q, want true", got)
}
if got := firstString(map[string]interface{}{"integer": int64(9)}, "integer"); got != "9" {
t.Fatalf("firstString(int64)=%q, want 9", got)
}
if got := versionString(" 42 "); got != "42" {
t.Fatalf("versionString(string)=%q, want 42", got)
}
if got := versionString(float64(8)); got != "8" {
t.Fatalf("versionString(float64)=%q, want 8", got)
}
if got := versionString(int64(11)); got != "11" {
t.Fatalf("versionString(int64)=%q, want 11", got)
}
if got := versionString(struct{}{}); got != "" {
t.Fatalf("versionString(struct)=%q, want empty", got)
}
}
// TestDrivePreviewAliasAndAvailabilityHelpers verifies alias lookup,
// normalization, and available-type de-duplication helpers.
func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
}
if got := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "999"}); got != nil {
t.Fatalf("previewAliasesForCandidate(unknown)=%v, want nil", got)
}
types := availableDrivePreviewTypes([]drivePreviewCandidate{
{Type: "pdf"},
{Type: "pdf"},
{Type: " jpg "},
{Type: ""},
})
if len(types) != 2 || types[0] != "pdf" || types[1] != "jpg" {
t.Fatalf("availableDrivePreviewTypes()=%v, want [pdf jpg]", types)
}
}
// TestDrivePreviewUnavailableHintAndContentTypeFallback verifies unavailable
// preview errors and content-type fallback extension inference.
func TestDrivePreviewUnavailableHintAndContentTypeFallback(t *testing.T) {
err := wrapDrivePreviewUnavailable("file_preview", "html", []drivePreviewCandidate{
{Type: "pdf"},
{Type: "jpg"},
}, "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(validationErr.Hint, "available preview types: pdf, jpg") {
t.Fatalf("hint=%q, want available preview types", validationErr.Hint)
}
err = wrapDrivePreviewUnavailable("file_preview", "html", nil, fmt.Sprintf("custom reason for %s", "html"))
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
}
resolution := drivePreviewExtensionByContentType("text/plain; charset=utf-8")
if resolution == nil || resolution.Ext != ".txt" {
t.Fatalf("drivePreviewExtensionByContentType()=%+v, want .txt", resolution)
}
}

View File

@@ -12,6 +12,8 @@ func Shortcuts() []common.Shortcut {
DriveCreateFolder,
DriveCreateShortcut,
DriveDownload,
DrivePreview,
DriveCover,
DriveAddComment,
DriveExport,
DriveExportDownload,

View File

@@ -15,6 +15,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+create-folder",
"+create-shortcut",
"+download",
"+preview",
"+cover",
"+version-history",
"+version-get",
"+version-revert",

View File

@@ -8,7 +8,6 @@ import (
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -51,11 +50,15 @@ const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
if !bodyEmpty && bodyFile != "" {
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
WithParams(
mailInvalidParam("--body", "mutually exclusive with --body-file"),
mailInvalidParam("--body-file", "mutually exclusive with --body"),
)
}
if bodyFile != "" {
if err := validatePath(bodyFile); err != nil {
return output.ErrValidation("--body-file: %v", err)
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
}
}
return nil
@@ -79,7 +82,7 @@ func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
if !hasTemplate && strings.TrimSpace(body) == "" {
return output.ErrValidation(message)
return mailValidationError("%s", message)
}
return nil
}
@@ -95,15 +98,15 @@ func validateRequiredResolvedBody(body string, hasTemplate bool, message string)
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
f, err := fio.Open(path)
if err != nil {
return "", output.ErrValidation("open --body-file %s: %v", path, err)
return "", mailValidationParamError("--body-file", "open --body-file %s: %v", path, err).WithCause(mailInputStatError(err))
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
if err != nil {
return "", output.ErrValidation("read --body-file %s: %v", path, err)
return "", mailValidationParamError("--body-file", "read --body-file %s: %v", path, err).WithCause(err)
}
if len(buf) > maxBodyFileSize {
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
return "", mailValidationParamError("--body-file", "--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
}
return string(buf), nil
}

View File

@@ -49,7 +49,7 @@ func encodeTextCharset(body []byte, label string) ([]byte, error) {
}
enc, _ := htmlcharset.Lookup(label)
if enc == nil {
return nil, fmt.Errorf("unsupported charset %q", label)
return nil, fmt.Errorf("unsupported charset %q", label) //nolint:forbidigo // intermediate draft charset error; mail command layer wraps into typed ValidationError.
}
var buf bytes.Buffer
writer := transform.NewWriter(&buf, enc.NewEncoder())

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft large-attachment parser errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft attachment limit errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft patch model errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft EML parser errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft patch application errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft calendar patch errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft serializer errors; mail command layer wraps into typed ValidationError.
package draft
import (

View File

@@ -4,10 +4,10 @@
package draft
import (
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,13 +31,13 @@ func mailboxPath(mailboxID string, segments ...string) string {
// draft_id, the input draftID is echoed back so callers always have a
// non-empty identifier to round-trip.
func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) {
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
if err != nil {
return DraftRaw{}, err
}
raw := extractRawEML(data)
if raw == "" {
return DraftRaw{}, fmt.Errorf("API response missing draft raw EML; the backend returned an empty raw body for this draft")
return DraftRaw{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "API response missing draft raw EML; the backend returned an empty raw body for this draft")
}
gotDraftID := extractDraftID(data)
if gotDraftID == "" {
@@ -55,13 +55,13 @@ func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw
// assembled the EML with emlbuilder; for high-level compose paths use the
// MailDraftCreate shortcut instead.
func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
return DraftResult{}, err
}
draftID := extractDraftID(data)
if draftID == "" {
return DraftResult{}, fmt.Errorf("API response missing draft_id")
return DraftResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "API response missing draft_id")
}
return DraftResult{
DraftID: draftID,
@@ -76,7 +76,7 @@ func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (Dr
// carries the (possibly re-issued) draft ID and the preview reference URL
// when the backend provides one.
func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
data, err := runtime.CallAPITyped("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
return DraftResult{}, err
}
@@ -99,7 +99,7 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (
if sendTime != "" {
bodyParams = map[string]interface{}{"send_time": sendTime}
}
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
return runtime.CallAPITyped("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
}
// extractDraftID returns the first non-empty draft identifier found in the

View File

@@ -53,6 +53,7 @@ import (
"time"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
@@ -61,9 +62,12 @@ const MaxEMLSize = 25 * 1024 * 1024 // 25 MB
// readFile reads the named file and returns its contents via FileIO.
func readFile(fio fileio.FileIO, path string) ([]byte, error) {
if _, err := validate.SafeInputPath(path); err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
f, err := fio.Open(path)
if err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err)
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
defer f.Close()
return io.ReadAll(f)
@@ -133,10 +137,10 @@ func New() Builder {
func validateHeaderValue(v string) error {
for _, r := range v {
if r != '\t' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("emlbuilder: header value contains control character: %q", v)
return fmt.Errorf("emlbuilder: header value contains control character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
if isHeaderDangerousUnicode(r) {
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v)
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
}
return nil
@@ -165,11 +169,11 @@ func isHeaderDangerousUnicode(r rune) bool {
// or non-printable ASCII characters, as required by RFC 5322 field-name syntax.
func validateHeaderName(n string) error {
if strings.ContainsAny(n, ":\r\n") {
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n)
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
for _, r := range n {
if r < 0x21 || r > 0x7e {
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n)
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
}
return nil
@@ -179,7 +183,7 @@ func validateHeaderName(n string) error {
// escape the quoted-string encoding used by mail.Address.String() and inject headers.
func validateDisplayName(name string) error {
if strings.ContainsAny(name, "\r\n") {
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name)
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
return nil
}
@@ -189,7 +193,7 @@ func validateDisplayName(name string) error {
func validateCID(cid string) error {
for _, r := range cid {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid)
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
}
return nil
@@ -672,10 +676,10 @@ func (b Builder) Build() ([]byte, error) {
return nil, b.err
}
if b.from.Address == "" {
return nil, fmt.Errorf("emlbuilder: From address is required")
return nil, fmt.Errorf("emlbuilder: From address is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
if !b.allowNoRecipients && len(b.to)+len(b.cc)+len(b.bcc) == 0 {
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required")
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
date := b.date
@@ -754,7 +758,7 @@ func (b Builder) Build() ([]byte, error) {
raw := buf.Bytes()
if len(raw) > MaxEMLSize {
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit",
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit", //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
float64(len(raw))/1024/1024, float64(MaxEMLSize)/1024/1024)
}
return raw, nil

View File

@@ -124,7 +124,7 @@ func CheckBlockedExtension(filename string) error {
return nil
}
if _, ok := blockedExtensions[ext]; ok {
return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext)
return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
}
return nil
}
@@ -156,7 +156,7 @@ var allowedInlineMIMETypes = map[string]struct{}{
func CheckInlineImageFormat(filename string, content []byte) (string, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if _, ok := allowedInlineExtensions[ext]; !ok {
return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext)
return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
}
detected := http.DetectContentType(content)
// DetectContentType may return params (e.g. "text/plain; charset=utf-8"),
@@ -165,7 +165,7 @@ func CheckInlineImageFormat(filename string, content []byte) (string, error) {
detected = strings.TrimSpace(detected[:i])
}
if _, ok := allowedInlineMIMETypes[detected]; !ok {
return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected)
return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected) //nolint:forbidigo // intermediate mail file-format check; mail command layer wraps into typed ValidationError.
}
return detected, nil
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
// flagName is a package-private snapshot of a pflag.Flag's identity.
@@ -21,8 +21,7 @@ type flagName struct {
}
// Candidate is a single suggested flag returned to the user when an
// unknown flag is detected. It is serialised into the ErrorEnvelope's
// error.detail.candidates[] array.
// unknown flag is detected.
type Candidate struct {
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
Flag string `json:"flag"`
@@ -56,9 +55,9 @@ func InstallOnMail(svc *cobra.Command) {
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
}
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a
// structured *output.ExitError carrying candidate suggestions. Any other
// error is passed through unchanged so cobra's existing handling kicks in.
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed
// validation error carrying candidate suggestions. Any other error is passed
// through unchanged so cobra's existing handling kicks in.
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
if err == nil {
return nil
@@ -83,22 +82,21 @@ func flagSuggestErrorFunc(c *cobra.Command, err error) error {
matches = []Candidate{}
}
hint := buildHint(c, matches)
detail := map[string]any{
"unknown": rawUnknownToken(token, isShorthand),
"command_path": c.CommandPath(),
"candidates": matches,
}
// Code is ExitAPI (=1), matching cobra's default unknown-flag exit
// code. The structured type discrimination lives in error.type.
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: err.Error(),
Hint: hint,
Detail: detail,
},
params := []errs.InvalidParam{{
Name: rawUnknownToken(token, isShorthand),
Reason: "unknown flag",
}}
for _, match := range matches {
reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance)
if match.Shorthand != "" {
reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand)
}
params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason})
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()).
WithHint("%s", hint).
WithParam(rawUnknownToken(token, isShorthand)).
WithParams(params...)
}
// parseUnknownToken extracts the offending flag name from a pflag error

View File

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
// --- suggest (long-flag) ---
@@ -175,35 +175,41 @@ func newFakeMailCmd() *cobra.Command {
return c
}
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsExitError(t *testing.T) {
func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError {
t.Helper()
var validationErr *errs.ValidationError
require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got)
p, ok := errs.ProblemOf(got)
require.True(t, ok, "expected typed Problem")
assert.Equal(t, errs.CategoryValidation, p.Category)
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
return validationErr
}
func paramReason(params []errs.InvalidParam, name string) (string, bool) {
for _, p := range params {
if p.Name == name {
return p.Reason, true
}
}
return "", false
}
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr), "expected *output.ExitError, got %T", got)
require.NotNil(t, exitErr.Detail)
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
assert.Equal(t, "unknown flag: --tos", exitErr.Detail.Message)
assert.Contains(t, exitErr.Detail.Hint, "--to")
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "unknown flag: --tos", validationErr.Message)
assert.Equal(t, "--tos", validationErr.Param)
assert.Contains(t, validationErr.Hint, "--to")
detail, ok := exitErr.Detail.Detail.(map[string]any)
require.True(t, ok, "Detail.Detail should be map[string]any")
assert.Equal(t, "--tos", detail["unknown"])
assert.Equal(t, cmd.CommandPath(), detail["command_path"])
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok, "candidates should be []Candidate")
require.NotEmpty(t, cands)
var foundTo bool
for _, c := range cands {
if c.Flag == "--to" {
foundTo = true
assert.Equal(t, "prefix", c.Reason)
break
}
}
assert.True(t, foundTo, "expected --to in candidates")
reason, ok := paramReason(validationErr.Params, "--tos")
require.True(t, ok, "unknown flag should be included in params")
assert.Equal(t, "unknown flag", reason)
reason, ok = paramReason(validationErr.Params, "--to")
require.True(t, ok, "expected --to in candidate params")
assert.Contains(t, reason, "candidate (prefix")
}
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
@@ -214,14 +220,13 @@ func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
}
func TestFlagSuggestErrorFunc_ExitCodeIsOne(t *testing.T) {
func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
// Hard contract — both compile-time and runtime guards:
assert.Equal(t, output.ExitAPI, exitErr.Code, "unknown_flag must use ExitAPI, not ExitValidation")
assert.Equal(t, 1, output.ExitAPI, "ExitAPI constant must remain 1")
p, ok := errs.ProblemOf(got)
require.True(t, ok)
assert.Equal(t, errs.CategoryValidation, p.Category)
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
}
// --- edge-case coverage ---
@@ -236,9 +241,8 @@ func TestInstallOnMail_InstallsHook(t *testing.T) {
InstallOnMail(c)
require.NotNil(t, c.FlagErrorFunc())
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr), "installed hook must produce *output.ExitError")
assert.Equal(t, "unknown_flag", exitErr.Detail.Type)
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "--tos", validationErr.Param)
}
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
@@ -249,50 +253,47 @@ func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
assert.Equal(t, "--tos", detail["unknown"], "value tail must be stripped before echoing")
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing")
reason, ok := paramReason(validationErr.Params, "--tos")
require.True(t, ok)
assert.Equal(t, "unknown flag", reason)
}
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
assert.Equal(t, "-b", detail["unknown"])
cands, ok := detail["candidates"].([]Candidate)
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "-b", validationErr.Param)
reason, ok := paramReason(validationErr.Params, "-b")
require.True(t, ok)
assert.Equal(t, "unknown flag", reason)
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
require.NotEmpty(t, cands)
assert.Equal(t, "--body", cands[0].Flag)
assert.Equal(t, "b", cands[0].Shorthand)
reason, ok = paramReason(validationErr.Params, "--body")
require.True(t, ok)
assert.Contains(t, reason, "candidate (prefix")
assert.Contains(t, reason, "shorthand=-b")
}
func TestFlagSuggestErrorFunc_CandidatesAlwaysArray(t *testing.T) {
func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) {
// A cobra command with no flags forces collectFlags → empty names →
// suggest → nil. The envelope must still expose candidates as a
// non-nil []Candidate so the JSON wire shape is "candidates: []"
// rather than "candidates: null".
// suggest → nil. The typed validation error must still expose the unknown
// flag in Params so downstream parsers have a stable structured field.
bare := &cobra.Command{Use: "mail"}
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
detail := exitErr.Detail.Detail.(map[string]any)
cands, ok := detail["candidates"].([]Candidate)
require.True(t, ok, "candidates must be []Candidate even when empty")
assert.NotNil(t, cands, "candidates must be non-nil empty slice, not nil")
assert.Empty(t, cands)
validationErr := requireFlagSuggestValidation(t, got)
assert.NotNil(t, validationErr.Params)
require.Len(t, validationErr.Params, 1)
assert.Equal(t, "--bogus", validationErr.Params[0].Name)
assert.Equal(t, "unknown flag", validationErr.Params[0].Reason)
}
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
cmd := newFakeMailCmd()
// Token with no plausible neighbor in {to, cc, subject, body}.
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
var exitErr *output.ExitError
require.True(t, errors.As(got, &exitErr))
assert.Contains(t, exitErr.Detail.Hint, "--help")
validationErr := requireFlagSuggestValidation(t, got)
assert.Contains(t, validationErr.Hint, "--help")
}
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {

View File

@@ -6,6 +6,7 @@ package mail
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
@@ -21,7 +22,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
@@ -111,7 +111,7 @@ func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail
return nil
}
if strings.TrimSpace(senderEmail) == "" {
return output.ErrValidation(
return mailValidationError(
"--request-receipt requires a resolvable sender address; specify a sender address where supported, or ensure the draft has a From address")
}
return nil
@@ -130,10 +130,10 @@ func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail
func validateHeaderAddress(addr string) error {
for _, r := range addr {
if r != '\t' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("address contains control character: %q", addr)
return mailValidationError("address contains control character: %q", addr)
}
if common.IsDangerousUnicode(r) {
return fmt.Errorf("address contains dangerous Unicode code point: %q", addr)
return mailValidationError("address contains dangerous Unicode code point: %q", addr)
}
}
return nil
@@ -324,7 +324,7 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string)
if mailboxID == "" {
mailboxID = "me"
}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "profile"), nil, nil)
if err != nil {
return "", err
}
@@ -336,7 +336,7 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string)
return email, nil
}
}
return "", fmt.Errorf("profile API returned no primary_email_address")
return "", mailInvalidResponseError("profile API returned no primary_email_address")
}
// extractPrimaryEmail returns the user's primary email address from a
@@ -503,12 +503,14 @@ func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (s
if err != nil {
return "", err
}
return resolveByID("folder", value, mailboxID, folders, func(item folderInfo) string { return item.ID })
return resolveByName("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
}
// resolveFolderName accepts either a folder ID or a folder name and returns
// the human-readable folder name. Used for output rendering where the user
// wants to see the name they originally chose, not the opaque ID.
// the canonical folder ID.
func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -542,11 +544,14 @@ func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (st
if err != nil {
return "", err
}
return resolveByID("label", value, mailboxID, labels, func(item labelInfo) string { return item.ID })
return resolveByName("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
}
// resolveLabelName accepts either a label ID or a label name and returns
// the human-readable label name (mirror of resolveFolderName for labels).
// the canonical label ID (mirror of resolveFolderName for labels).
func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -846,9 +851,11 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
if err := validateFolderReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "folders"), nil, nil)
if err != nil {
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
return nil, mailAppendProblemHint(
mailDecorateProblemMessage(err, "unable to resolve --folder: failed to list folders"),
resolveLookupHint("folder", mailboxID))
}
items, _ := data["items"].([]interface{})
folders := make([]folderInfo, 0, len(items))
@@ -871,9 +878,11 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe
if err := validateLabelReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "labels"), nil, nil)
if err != nil {
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
return nil, mailAppendProblemHint(
mailDecorateProblemMessage(err, "unable to resolve --label: failed to list labels"),
resolveLookupHint("label", mailboxID))
}
items, _ := data["items"].([]interface{})
labels := make([]labelInfo, 0, len(items))
@@ -891,26 +900,9 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe
return labels, nil
}
// resolveByID looks up input as an ID in items, returning input itself when
// found. kind ("folder" / "label") and mailboxID are used to construct the
// not-found hint. Generic over T so the same logic serves both folder and
// label tables.
func resolveByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
return id, nil
}
}
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveByName looks up input as a name in items and returns the matching
// ID. Errors out on duplicates so callers get a clear "ambiguous name"
// signal rather than silently picking one match.
// resolveByName looks up input as an exact ID first, then as a name, and
// returns the matching ID. Errors out on duplicate names so callers get a clear
// "ambiguous name" signal rather than silently picking one match.
func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -919,7 +911,7 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T)
for _, item := range items {
if id := idFn(item); id != "" && id == value {
return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind)
return id, nil
}
}
@@ -943,9 +935,9 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T)
return matches[0], nil
}
if len(matches) > 1 {
return "", output.ErrValidation("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
return "", mailValidationError("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
}
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveNameValueByID is the inverse of resolveByID: it looks up an ID
@@ -959,18 +951,18 @@ func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn
if id := idFn(item); id != "" && id == value {
name := strings.TrimSpace(nameFn(item))
if name == "" {
return "", output.ErrValidation("%s %q has empty name; cannot use it with query filters", kind, value)
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
}
return name, nil
}
}
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveNameValueByNameAllowDuplicates is like resolveByName but tolerates
// duplicate names — returning the first match. Used in query-style contexts
// where ambiguity is acceptable because the API itself disambiguates server-
// side.
// resolveNameValueByNameAllowDuplicates looks up input as an exact ID first,
// then as a name, and returns the matching name. Duplicate names are tolerated
// by returning the first match. Used in query-style contexts where ambiguity is
// acceptable because the API itself disambiguates server-side.
func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
@@ -978,7 +970,11 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string,
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind)
name := strings.TrimSpace(nameFn(item))
if name == "" {
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
}
return name, nil
}
}
lower := strings.ToLower(value)
@@ -989,7 +985,7 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string,
}
return name, nil
}
return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveLookupHint returns the CLI command a user should run to list
@@ -1015,13 +1011,13 @@ func resolveLookupHint(kind, mailboxID string) string {
// html=false -> format=plain_text_full (server omits body_html)
func fetchFullMessage(runtime *common.RuntimeContext, mailboxID, messageID string, html bool) (map[string]interface{}, error) {
params := map[string]interface{}{"format": messageGetFormat(html)}
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
if err != nil {
return nil, err
}
msg, _ := data["message"].(map[string]interface{})
if msg == nil {
return nil, fmt.Errorf("API response missing message field")
return nil, mailInvalidResponseError("API response missing message field")
}
return msg, nil
}
@@ -1039,7 +1035,7 @@ func fetchFullMessages(runtime *common.RuntimeContext, mailboxID string, message
if end > len(messageIDs) {
end = len(messageIDs)
}
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
"format": messageGetFormat(html),
"message_ids": messageIDs[start:end],
})
@@ -1232,7 +1228,7 @@ type calendarEventOutput struct {
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
func fetchAttachmentURLs(runtime *common.RuntimeContext, mailboxID, messageID string, ids []string) (map[string]string, []warningEntry) {
callAPI := func(url string) (map[string]interface{}, error) {
return runtime.CallAPI("GET", url, nil, nil)
return runtime.CallAPITyped("GET", url, nil, nil)
}
emitWarning := func(w warningEntry) {
fmt.Fprintf(runtime.IO().ErrOut, "warning: code=%s message_id=%s attachment_id=%s retryable=%t detail=%s\n", w.Code, w.MessageID, w.AttachmentID, w.Retryable, w.Detail)
@@ -1668,7 +1664,7 @@ func validateForwardAttachmentURLs(src composeSourceMessage) error {
}
}
if len(missing) > 0 {
return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
}
return nil
}
@@ -1683,7 +1679,7 @@ func validateInlineImageURLs(src composeSourceMessage) error {
}
}
if len(missing) > 0 {
return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
}
return nil
}
@@ -1786,41 +1782,51 @@ func toInlineSourceParts(out normalizedMessageForCompose) []inlineSourcePart {
func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL string) ([]byte, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, fmt.Errorf("invalid attachment download URL: %w", err)
return nil, mailInvalidResponseError("invalid attachment download URL: %v", err).WithCause(err)
}
if u.Scheme != "https" {
return nil, fmt.Errorf("attachment download URL must use https (got %q)", u.Scheme)
return nil, mailInvalidResponseError("attachment download URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, fmt.Errorf("attachment download URL has no host")
return nil, mailInvalidResponseError("attachment download URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, fmt.Errorf("failed to get HTTP client: %w", err)
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
}
req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build attachment download request: %w", err)
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to build attachment download request: %v", err).WithCause(err)
}
// Do NOT send Authorization: the download_url is a pre-signed URL with an
// authcode embedded in the query string. Attaching the Bearer token would
// leak it to whatever host the URL points at (SSRF / token exfiltration).
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to download attachment: %w", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to download attachment: %v", err).WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("failed to download attachment: HTTP %d", resp.StatusCode)
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "failed to download attachment: HTTP %d", resp.StatusCode).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return nil, errs.NewAPIError(subtype, "failed to download attachment: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode)
}
limitedReader := io.LimitReader(resp.Body, int64(MaxAttachmentDownloadBytes)+1)
data, err := io.ReadAll(limitedReader)
if err != nil {
return nil, fmt.Errorf("failed to read attachment content: %w", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to read attachment content: %v", err).WithCause(err)
}
if len(data) > MaxAttachmentDownloadBytes {
return nil, fmt.Errorf("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024)
return nil, mailFailedPreconditionError("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024).
WithHint("download or forward this large attachment outside the inline/small-attachment path")
}
return data, nil
}
@@ -2062,7 +2068,7 @@ func parsePriority(value string) (string, error) {
case "low":
return "5", nil
default:
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
return "", mailValidationParamError("--priority", "invalid --priority value %q: expected high, normal, or low", value)
}
}
@@ -2232,7 +2238,7 @@ func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error {
if len(userCIDs) > 0 {
orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs)
if len(orphaned) > 0 {
return fmt.Errorf("inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
return mailValidationParamError("--inline", "inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
}
}
return nil
@@ -2251,7 +2257,7 @@ func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Bui
for _, img := range images {
content, err := downloadAttachmentContent(runtime, img.DownloadURL)
if err != nil {
return bld, nil, 0, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
return bld, nil, 0, mailDecorateProblemMessage(err, "failed to download inline resource %s", img.Filename)
}
cid := normalizeInlineCID(img.CID)
if cid == "" {
@@ -2284,14 +2290,14 @@ func parseInlineSpecs(raw string) ([]InlineSpec, error) {
}
var specs []InlineSpec
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
return nil, fmt.Errorf("--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %w", err)
return nil, mailValidationParamError("--inline", "--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %v", err).WithCause(err)
}
for i, s := range specs {
if strings.TrimSpace(s.CID) == "" {
return nil, fmt.Errorf("--inline entry %d: \"cid\" must not be empty", i)
return nil, mailValidationParamError("--inline", "--inline entry %d: \"cid\" must not be empty", i)
}
if strings.TrimSpace(s.FilePath) == "" {
return nil, fmt.Errorf("--inline entry %d: \"file_path\" must not be empty", i)
return nil, mailValidationParamError("--inline", "--inline entry %d: \"file_path\" must not be empty", i)
}
}
return specs, nil
@@ -2318,7 +2324,11 @@ func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
}
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
if runtime.Str(f) != "" {
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
return mailValidationError("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event").
WithParams(
mailInvalidParam("--send-time", "mutually exclusive with --event-*"),
mailInvalidParam("--event-*", "mutually exclusive with --send-time"),
)
}
}
return nil
@@ -2332,15 +2342,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
return nil
}
if !runtime.Bool("confirm-send") {
return output.ErrValidation("--send-time requires --confirm-send to be set")
return mailValidationParamError("--send-time", "--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
return mailValidationParamError("--send-time", "--send-time must be a valid Unix timestamp in seconds, got %q", sendTime).WithCause(err)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
return mailValidationParamError("--send-time", "--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
@@ -2429,7 +2439,12 @@ func validateLabelReadScope(runtime *common.RuntimeContext) error {
// all three (to/cc/bcc) are empty or whitespace-only.
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
return mailValidationError("at least one recipient (--to, --cc, or --bcc) is required").
WithParams(
mailInvalidParam("--to", "at least one recipient is required"),
mailInvalidParam("--cc", "at least one recipient is required"),
mailInvalidParam("--bcc", "at least one recipient is required"),
)
}
return validateRecipientCount(to, cc, bcc)
}
@@ -2439,7 +2454,12 @@ func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
func validateRecipientCount(to, cc, bcc string) error {
count := len(ParseMailboxList(to)) + len(ParseMailboxList(cc)) + len(ParseMailboxList(bcc))
if count > MaxRecipientCount {
return fmt.Errorf("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount)
return mailValidationError("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount).
WithParams(
mailInvalidParam("--to", "recipient count contributes to combined limit"),
mailInvalidParam("--cc", "recipient count contributes to combined limit"),
mailInvalidParam("--bcc", "recipient count contributes to combined limit"),
)
}
return nil
}
@@ -2451,10 +2471,14 @@ func validateRecipientCount(to, cc, bcc string) error {
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
if strings.TrimSpace(inlineFlag) != "" {
if plainText {
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
return mailValidationError("--inline is not supported with --plain-text (inline images require HTML body)").
WithParams(
mailInvalidParam("--inline", "requires HTML body"),
mailInvalidParam("--plain-text", "mutually exclusive with --inline"),
)
}
if body != "" && !bodyIsHTML(body) {
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
return mailValidationParamError("--inline", "--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
@@ -2536,7 +2560,12 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
return mailValidationError("--event-summary, --event-start, and --event-end must all be provided together").
WithParams(
mailInvalidParam("--event-summary", "required with --event-start/--event-end"),
mailInvalidParam("--event-start", "required with --event-summary/--event-end"),
mailInvalidParam("--event-end", "required with --event-summary/--event-start"),
)
}
if summary == "" {
return nil
@@ -2553,14 +2582,14 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
startT, err := parseISO8601(start)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
return time.Time{}, time.Time{}, mailValidationError("start: invalid ISO 8601 time %q", start).WithCause(err)
}
endT, err := parseISO8601(end)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
return time.Time{}, time.Time{}, mailValidationError("end: invalid ISO 8601 time %q", end).WithCause(err)
}
if !endT.After(startT) {
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
return time.Time{}, time.Time{}, mailValidationError("end time must be after start time")
}
return startT, endT, nil
}
@@ -2569,12 +2598,27 @@ func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
// error with the caller's flag-name prefix so users see the exact flag
// that caused the failure.
func prefixEventRangeError(flagPrefix string, err error) error {
msg := err.Error()
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
var validationErr *errs.ValidationError
msg := p.Message
switch {
case strings.HasPrefix(msg, "start: "):
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
p.Message = fmt.Sprintf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
p.Subtype = errs.SubtypeInvalidArgument
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
validationErr.Param = flagPrefix + "start"
}
return err
case strings.HasPrefix(msg, "end: "):
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
p.Message = fmt.Sprintf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
p.Subtype = errs.SubtypeInvalidArgument
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
validationErr.Param = flagPrefix + "end"
}
return err
default:
return err
}
@@ -2595,7 +2639,7 @@ func parseISO8601(s string) (time.Time, error) {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
return time.Time{}, mailValidationError("cannot parse %q as ISO 8601", s)
}
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
@@ -2614,8 +2658,8 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
return output.ErrValidation(
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
return mailValidationParamError("--mailbox",
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; "+
"pass an explicit email address, e.g. --mailbox alice@example.com")
}
return nil
@@ -2627,7 +2671,7 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
// fetchFullMessages chunks backend requests into batches of 20.
func validateMessageIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
return nil, mailValidationParamError("--message-ids", "--message-ids is required; provide one or more message IDs separated by commas")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
@@ -2635,16 +2679,16 @@ func validateMessageIDs(raw string) ([]string, error) {
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if part != id {
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateBatchGetMessageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
}
seen[id] = struct{}{}
ids = append(ids, id)
@@ -2654,11 +2698,14 @@ func validateMessageIDs(raw string) ([]string, error) {
func validateBatchGetMessageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
}
decoded, err := base64.URLEncoding.DecodeString(id)
if err != nil || len(decoded) == 0 {
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
decoded, rawErr := base64.RawURLEncoding.DecodeString(id)
if rawErr != nil {
decoded, rawErr = base64.URLEncoding.DecodeString(id)
}
if rawErr != nil || len(decoded) == 0 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
}
return nil
}

View File

@@ -1353,6 +1353,34 @@ func TestValidateComposeInlineAndAttachments(t *testing.T) {
})
}
func TestResolveByNameAcceptsExactID(t *testing.T) {
folders := []folderInfo{{ID: "fld_custom", Name: "Team"}}
got, err := resolveByName("folder", "fld_custom", "me", folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
t.Fatalf("resolveByName returned error: %v", err)
}
if got != "fld_custom" {
t.Fatalf("resolveByName exact ID = %q, want fld_custom", got)
}
}
func TestResolveNameValueByNameAllowDuplicatesAcceptsExactID(t *testing.T) {
folders := []folderInfo{{ID: "fld_custom", Name: "Parent/Team"}}
got, err := resolveNameValueByNameAllowDuplicates("folder", "fld_custom", "me", folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
t.Fatalf("resolveNameValueByNameAllowDuplicates returned error: %v", err)
}
if got != "Parent/Team" {
t.Fatalf("query name for exact ID = %q, want Parent/Team", got)
}
}
// newRequestReceiptRuntime registers the --request-receipt bool flag alone
// (no --from), so requireSenderForRequestReceipt tests can drive the flag
// directly without pulling in unrelated compose plumbing.
@@ -1522,16 +1550,16 @@ func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
}
func TestPrefixEventRangeError(t *testing.T) {
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
start := mailValidationError("start: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
end := mailValidationError("end: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
// Non-prefixed error passes through unchanged.
other := fmt.Errorf("end time must be after start time")
other := mailValidationError("end time must be after start time")
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
t.Errorf("got %q", got)
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
@@ -121,11 +122,11 @@ func statAttachmentFiles(fio fileio.FileIO, paths []string) ([]attachmentFile, e
}
name := filepath.Base(p)
if err := filecheck.CheckBlockedExtension(name); err != nil {
return nil, err
return nil, mailValidationError("%v", err).WithCause(err)
}
info, err := fio.Stat(p)
if err != nil {
return nil, fmt.Errorf("failed to stat attachment %s: %w", p, err)
return nil, mailInputStatError(err)
}
files = append(files, attachmentFile{
Path: p,
@@ -144,7 +145,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
}
userOpenId := runtime.UserOpenId()
if userOpenId == "" {
return nil, fmt.Errorf("large attachment upload requires user identity (user open_id not available)")
return nil, mailFailedPreconditionError("large attachment upload requires user identity (user open_id not available)")
}
results := make([]largeAttachmentResult, 0, len(files))
@@ -156,7 +157,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
err error
)
if f.Data != nil {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FileName: f.FileName,
FileSize: f.Size,
ParentType: "email",
@@ -164,7 +165,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
Reader: bytes.NewReader(f.Data),
})
} else if f.Size <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: f.Path,
FileName: f.FileName,
FileSize: f.Size,
@@ -172,7 +173,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
ParentNode: &userOpenId,
})
} else {
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
fileToken, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: f.Path,
FileName: f.FileName,
FileSize: f.Size,
@@ -181,7 +182,7 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext,
})
}
if err != nil {
return nil, fmt.Errorf("failed to upload large attachment %s: %w", f.FileName, err)
return nil, mailDecorateProblemMessage(err, "failed to upload large attachment %s", f.FileName)
}
results = append(results, largeAttachmentResult{
@@ -397,7 +398,7 @@ func processLargeAttachments(
) (emlbuilder.Builder, error) {
totalCount := extraAttachCount + len(attachPaths)
if totalCount > MaxAttachmentCount {
return bld, fmt.Errorf("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
return bld, mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
}
files, err := statAttachmentFiles(runtime.FileIO(), attachPaths)
@@ -407,7 +408,7 @@ func processLargeAttachments(
for _, f := range files {
if f.Size > MaxLargeAttachmentSize {
return bld, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return bld, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
}
@@ -422,7 +423,7 @@ func processLargeAttachments(
}
if htmlBody == "" && textBody == "" {
return bld, fmt.Errorf("large attachments require a body; " +
return bld, mailFailedPreconditionError("large attachments require a body; " +
"empty messages cannot include the download link")
}
@@ -431,7 +432,7 @@ func processLargeAttachments(
for _, f := range files {
totalBytes += f.Size
}
return bld, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
return bld, mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
"large attachment upload requires user identity (--as user)",
float64(totalBytes)/1024/1024)
}
@@ -455,7 +456,7 @@ func processLargeAttachments(
}
idsJSON, err := json.Marshal(ids)
if err != nil {
return bld, fmt.Errorf("failed to encode large attachment IDs: %w", err)
return bld, errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
}
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
@@ -588,7 +589,7 @@ func preprocessLargeAttachmentsForDraftEdit(
// Check 3GB single file limit.
for _, f := range files {
if f.Size > MaxLargeAttachmentSize {
return patch, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return patch, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
}
@@ -606,7 +607,7 @@ func preprocessLargeAttachmentsForDraftEdit(
hasHTML := draftpkg.FindHTMLBodyPart(snapshot.Body) != nil
hasText := draftpkg.FindTextBodyPart(snapshot.Body) != nil
if !hasHTML && !hasText {
return patch, fmt.Errorf("large attachments require a body; " +
return patch, mailFailedPreconditionError("large attachments require a body; " +
"empty drafts cannot include the download link")
}
@@ -616,7 +617,7 @@ func preprocessLargeAttachmentsForDraftEdit(
for _, f := range files {
totalBytes += f.Size
}
return patch, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
return patch, mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
"large attachment upload requires user identity (--as user)",
float64(totalBytes)/1024/1024)
}
@@ -672,7 +673,7 @@ func preprocessLargeAttachmentsForDraftEdit(
}
idsJSON, err := json.Marshal(merged)
if err != nil {
return patch, fmt.Errorf("failed to encode large attachment IDs: %w", err)
return patch, errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
}
headerValue := base64.StdEncoding.EncodeToString(idsJSON)
if existingIdx >= 0 {

View File

@@ -974,7 +974,7 @@ func TestStatAttachmentFiles_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "failed to stat") {
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -395,8 +395,7 @@ func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) {
return cleaned, dropped
}
// hintForBlockedTag returns a hint for an error-blocked tag (matching
// the `output.ErrWithHint` convention used elsewhere in the cli).
// hintForBlockedTag returns a hint for an error-blocked tag.
func hintForBlockedTag(tag string) string {
switch tag {
case "script":

View File

@@ -59,7 +59,7 @@ var MailDeclineReceipt = common.Shortcut{
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
return mailDecorateProblemMessage(err, "failed to fetch original message")
}
out := map[string]interface{}{
@@ -77,14 +77,14 @@ var MailDeclineReceipt = common.Shortcut{
return nil
}
if _, err := runtime.CallAPI("PUT",
if _, err := runtime.CallAPITyped("PUT",
mailboxPath(mailboxID, "messages", messageID, "modify"),
nil,
map[string]interface{}{
"remove_label_ids": []string{readReceiptRequestLabel},
},
); err != nil {
return fmt.Errorf("failed to clear READ_RECEIPT_REQUEST label: %w", err)
return mailDecorateProblemMessage(err, "failed to clear READ_RECEIPT_REQUEST label")
}
out["declined"] = true

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -91,7 +90,7 @@ var MailDraftCreate = common.Shortcut{
return err
}
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
@@ -176,10 +175,10 @@ var MailDraftCreate = common.Shortcut{
})
}
if strings.TrimSpace(input.Subject) == "" {
return output.ErrValidation("effective subject is empty after applying template; pass --subject explicitly")
return mailValidationParamError("--subject", "effective subject is empty after applying template; pass --subject explicitly")
}
if strings.TrimSpace(input.Body) == "" {
return output.ErrValidation("effective body is empty after applying template; pass --body explicitly")
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
@@ -192,7 +191,7 @@ var MailDraftCreate = common.Shortcut{
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
return mailDecorateProblemMessage(err, "create draft failed")
}
out := map[string]interface{}{"draft_id": draftResult.DraftID}
if draftResult.Reference != "" {
@@ -250,7 +249,7 @@ func buildRawEMLForDraftCreate(
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", lintApplied, lintBlocked, fmt.Errorf("unable to determine sender email; please specify --from explicitly")
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
}
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
@@ -285,7 +284,7 @@ func buildRawEMLForDraftCreate(
}
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
if parseErr != nil {
return "", lintApplied, lintBlocked, output.ErrValidation("%v", parseErr)
return "", lintApplied, lintBlocked, parseErr
}
var autoResolvedPaths []string
var composedHTMLBody string
@@ -300,7 +299,7 @@ func buildRawEMLForDraftCreate(
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return "", lintApplied, lintBlocked, resolveErr
return "", lintApplied, lintBlocked, mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
}
resolved = injectSignatureIntoBody(resolved, sigResult)
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
@@ -365,7 +364,7 @@ func buildRawEMLForDraftCreate(
}
rawEML, buildErr := bld.BuildBase64URL()
if buildErr != nil {
return "", lintApplied, lintBlocked, output.ErrValidation("build EML failed: %v", buildErr)
return "", lintApplied, lintBlocked, mailValidationError("build EML failed: %v", buildErr).WithCause(buildErr)
}
return rawEML, lintApplied, lintBlocked, nil
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/ics"
@@ -88,7 +87,7 @@ var MailDraftEdit = common.Shortcut{
}
draftID := runtime.Str("draft-id")
if draftID == "" {
return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
return mailValidationParamError("--draft-id", "--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template")
}
mailboxID := resolveComposeMailboxID(runtime)
if runtime.Bool("inspect") {
@@ -100,11 +99,11 @@ var MailDraftEdit = common.Shortcut{
}
rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
if err != nil {
return fmt.Errorf("read draft raw EML failed: %w", err)
return mailDecorateProblemMessage(err, "read draft raw EML failed")
}
snapshot, err := draftpkg.Parse(rawDraft)
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
return mailFailedPreconditionError("parse draft raw EML failed: %v", err).WithCause(err)
}
// Pre-process ops that need snapshot context: resolve signature using
// the draft's From address, and build ICS for set_calendar using the
@@ -123,8 +122,8 @@ var MailDraftEdit = common.Shortcut{
// Going straight into PatchOp.Value would bypass emlbuilder's
// validateHeaderValue gate, so repeat the check here explicitly.
if err := validateHeaderAddress(draftFromEmail); err != nil {
return output.ErrValidation(
"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err)
return mailFailedPreconditionError(
"cannot set --request-receipt: draft From address is unsafe for a header (%v)", err).WithCause(err)
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
Op: "set_header",
@@ -147,11 +146,11 @@ var MailDraftEdit = common.Shortcut{
if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil {
parsed := ics.ParseEvent(string(calPart.Body))
if parsed == nil || !parsed.IsLarkDraft {
return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
return mailFailedPreconditionError("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
}
}
if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil {
return output.ErrValidation("set_calendar: %v", err)
return prefixEventRangeError("set_calendar: ", err)
}
// Derive effective To/Cc by replaying all pending recipient ops so
// the ICS ATTENDEE list matches the final post-edit recipients.
@@ -166,7 +165,7 @@ var MailDraftEdit = common.Shortcut{
joinAddresses(ccAddrs),
)
if calData == nil {
return output.ErrValidation("set_calendar: failed to build ICS from event fields")
return mailValidationError("set_calendar: failed to build ICS from event fields")
}
patch.Ops[i].CalendarICS = calData
}
@@ -206,16 +205,16 @@ var MailDraftEdit = common.Shortcut{
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if len(patch.Ops) > 0 {
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
return output.ErrValidation("apply draft patch failed: %v", err)
return mailValidationError("apply draft patch failed: %v", err).WithCause(err)
}
}
serialized, err := draftpkg.Serialize(snapshot)
if err != nil {
return output.ErrValidation("serialize draft failed: %v", err)
return mailValidationError("serialize draft failed: %v", err).WithCause(err)
}
updateResult, err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized)
if err != nil {
return fmt.Errorf("update draft failed: %w", err)
return mailDecorateProblemMessage(err, "update draft failed")
}
projection := draftpkg.Project(snapshot)
out := map[string]interface{}{
@@ -270,11 +269,11 @@ var MailDraftEdit = common.Shortcut{
func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID string) error {
rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID)
if err != nil {
return fmt.Errorf("read draft raw EML failed: %w", err)
return mailDecorateProblemMessage(err, "read draft raw EML failed")
}
snapshot, err := draftpkg.Parse(rawDraft)
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
return mailFailedPreconditionError("parse draft raw EML failed: %v", err).WithCause(err)
}
projection := draftpkg.Project(snapshot)
out := map[string]interface{}{
@@ -422,7 +421,12 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
if bodyVal != "" {
for _, op := range patch.Ops {
if op.Op == "set_body" || op.Op == "set_reply_body" {
return patch, output.ErrValidation("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other")
return patch, mailValidationError("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other").
WithParams(
mailInvalidParam("--body", "mutually exclusive with --patch-file body ops"),
mailInvalidParam("--body-file", "mutually exclusive with --patch-file body ops"),
mailInvalidParam("--patch-file", "mutually exclusive with direct body flags"),
)
}
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_body", Value: bodyVal})
@@ -448,20 +452,29 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
hasEventSet := runtime.Str("set-event-summary") != ""
hasEventRemove := runtime.Bool("remove-event")
if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") {
return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
return patch, mailValidationParamError("--set-event-summary", "--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
}
if hasEventSet && hasEventRemove {
return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive")
return patch, mailValidationError("--set-event-summary and --remove-event are mutually exclusive").
WithParams(
mailInvalidParam("--set-event-summary", "mutually exclusive with --remove-event"),
mailInvalidParam("--remove-event", "mutually exclusive with --set-event-summary"),
)
}
if hasEventSet {
summary := runtime.Str("set-event-summary")
start := runtime.Str("set-event-start")
end := runtime.Str("set-event-end")
if summary == "" || start == "" || end == "" {
return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together")
return patch, mailValidationError("--set-event-summary, --set-event-start, and --set-event-end must all be provided together").
WithParams(
mailInvalidParam("--set-event-summary", "required with --set-event-start/--set-event-end"),
mailInvalidParam("--set-event-start", "required with --set-event-summary/--set-event-end"),
mailInvalidParam("--set-event-end", "required with --set-event-summary/--set-event-start"),
)
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error())
return patch, prefixEventRangeError("--set-event-", err)
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
Op: "set_calendar",
@@ -475,7 +488,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
}
if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") {
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
return patch, mailValidationError("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
}
if len(patch.Ops) == 0 {
// --request-receipt only: Validate() would reject empty Ops, so skip it
@@ -483,7 +496,10 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
// the draft's From address is known.
return patch, nil
}
return patch, patch.Validate()
if err := patch.Validate(); err != nil {
return patch, mailValidationError("%v", err).WithCause(err)
}
return patch, nil
}
// loadPatchFile reads and JSON-decodes a patch file from a relative path
@@ -492,19 +508,25 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
// internal stack traces.
func loadPatchFile(runtime *common.RuntimeContext, path string) (draftpkg.Patch, error) {
var patch draftpkg.Patch
if err := runtime.ValidatePath(path); err != nil {
return patch, mailValidationParamError("--patch-file", "--patch-file %q: %v", path, err).WithCause(mailInputStatError(err))
}
f, err := runtime.FileIO().Open(path)
if err != nil {
return patch, fmt.Errorf("--patch-file %q: %w", path, err)
return patch, mailValidationParamError("--patch-file", "--patch-file %q: %v", path, err).WithCause(mailInputStatError(err))
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return patch, err
return patch, mailValidationParamError("--patch-file", "read --patch-file %q: %v", path, err).WithCause(err)
}
if err := json.Unmarshal(data, &patch); err != nil {
return patch, fmt.Errorf("parse patch file: %w", err)
return patch, mailValidationParamError("--patch-file", "parse patch file: %v", err).WithCause(err)
}
return patch, patch.Validate()
if err := patch.Validate(); err != nil {
return patch, mailValidationParamError("--patch-file", "validate patch file: %v", err).WithCause(err)
}
return patch, nil
}
// buildDraftEditPatchTemplate returns the JSON template emitted by

View File

@@ -4,8 +4,11 @@
package mail
import (
"errors"
"os"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/spf13/cobra"
@@ -82,6 +85,43 @@ func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
}
}
func TestLoadPatchFileRejectsUnsafePathWithTypedParam(t *testing.T) {
chdirTemp(t)
f, _, _, _ := mailShortcutTestFactory(t)
rt := &common.RuntimeContext{Cmd: &cobra.Command{Use: "test"}, Factory: f, Config: mailTestConfig()}
_, err := loadPatchFile(rt, "../patch.json")
if err == nil {
t.Fatal("expected unsafe patch path to fail")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--patch-file" {
t.Fatalf("param = %q, want --patch-file", validationErr.Param)
}
}
func TestLoadPatchFileValidateFailureKeepsPatchFileParam(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("patch.json", []byte(`{"ops":[]}`), 0o644); err != nil {
t.Fatal(err)
}
f, _, _, _ := mailShortcutTestFactory(t)
rt := &common.RuntimeContext{Cmd: &cobra.Command{Use: "test"}, Factory: f, Config: mailTestConfig()}
_, err := loadPatchFile(rt, "patch.json")
if err == nil {
t.Fatal("expected invalid patch file to fail")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--patch-file" {
t.Fatalf("param = %q, want --patch-file", validationErr.Param)
}
}
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
patch, err := buildDraftEditPatch(rt)

View File

@@ -5,11 +5,10 @@ package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -46,11 +45,21 @@ type failedDraft struct {
// "success_count": 2,
// "failure_count": 1,
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
// "failed":[{"draft_id":..., "error":...}]
// "failed":[{"draft_id":..., "error":...}],
// "aborted": true,
// "abort_error": {"type":..., "subtype":..., "code":..., "message":..., "hint":...}
// }
//
// failed is marked omitempty so a fully successful batch returns a clean shape
// without an empty array.
//
// aborted reports an account-level abort: the failure repeats identically for
// every draft, so the remaining drafts were not attempted and retrying the
// batch as-is fails the same way. abort_error carries the typed error that
// triggered the abort (same wire shape as a stderr error envelope's error
// object) so callers can route recovery from stdout alone. A --stop-on-error
// stop does NOT set aborted: there the failure is draft-level and the caller
// chose to stop early.
type batchSendOutput struct {
MailboxID string `json:"mailbox_id"`
Total int `json:"total"`
@@ -58,6 +67,8 @@ type batchSendOutput struct {
FailureCount int `json:"failure_count"`
Sent []sentDraft `json:"sent"`
Failed []failedDraft `json:"failed,omitempty"`
Aborted bool `json:"aborted,omitempty"`
AbortError interface{} `json:"abort_error,omitempty"`
}
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
@@ -66,9 +77,9 @@ type batchSendOutput struct {
// drafts are user-owned resources and bot has no coherent semantics here.
//
// Output schema is the batchSendOutput type above. Partial failures (any
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
// agents can distinguish "all sent" from "some sent" without parsing the
// success_count field.
// failed[]) emit an ok:false multi-status envelope so that agents can
// distinguish "all sent" from "some sent" without parsing the success_count
// field.
var MailDraftSend = common.Shortcut{
Service: "mail",
Command: "+draft-send",
@@ -101,14 +112,16 @@ var MailDraftSend = common.Shortcut{
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
// no empty elements).
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
// runtime.CallAPI. Per-draft outcomes:
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
// runtime.CallAPITyped. Per-draft outcomes:
// - fatal err (isFatalSendErr) → abort immediately (bypasses
// --stop-on-error): with earlier progress, emit the aborted ledger as the
// single failure result; with none, return the typed error directly.
// - recoverable err → append to failed[]; honor --stop-on-error.
// - success + automation_send_disable signal → return immediately with
// ExitAPI/"automation_send_disabled".
// - success + automation_send_disable signal → abort the same way with a
// failed-precondition error.
// - success → append to sent[].
// 4. Emit batchSendOutput via runtime.Out.
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
// 5. If any draft failed, emit ok:false via runtime.OutPartialFailure.
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveComposeMailboxID(rt)
draftIDs, err := normalizedDraftSendIDs(rt)
@@ -122,9 +135,9 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
idx := i + 1
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
idx, len(draftIDs), sanitizeForSingleLine(id))
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
// Direct CallAPITyped rather than draftpkg.Send: this shortcut never sends
// a body, so the helper's send_time-aware envelope would add no value.
data, err := rt.CallAPI("POST",
data, err := rt.CallAPITyped("POST",
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
if err != nil {
if isFatalSendErr(err) {
@@ -132,13 +145,15 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
hadProgress := out.hasProgress()
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if hadProgress {
emitDraftSendOutput(rt, &out)
}
// Account- / mailbox-level failures (auth, permission, network,
// quota) will repeat identically for every remaining draft —
// abort immediately so the caller sees a single clear error
// instead of 100 redundant failed[] entries.
// instead of 100 redundant failed[] entries. With earlier
// progress the aborted ledger is the single failure result;
// with none, stdout stays empty and the typed error envelope is.
if hadProgress {
return emitDraftSendAborted(rt, &out, err)
}
return err
}
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
@@ -150,17 +165,19 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
continue
}
if reason := extractAutomationDisabledReason(data); reason != "" {
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
"automation send is disabled for this mailbox: %s", reason)
err := mailFailedPreconditionError(
"automation send is disabled for this mailbox: %s", reason).
WithHint("enable automation send for this mailbox, or send the draft from the Lark client")
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
if out.hasProgress() {
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
emitDraftSendOutput(rt, &out)
}
// HTTP success (code: 0) but the backend signaled automation send
// is disabled — every subsequent send will fail the same way, so
// abort the batch with a single descriptive error.
// abort the batch with a single failure result: the aborted ledger
// when earlier drafts made progress, the typed error otherwise.
if out.hasProgress() {
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
return emitDraftSendAborted(rt, &out, err)
}
return err
}
s := sentDraft{DraftID: id}
@@ -179,13 +196,11 @@ func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
idx, len(draftIDs), sanitizeForSingleLine(id))
}
}
emitDraftSendOutput(rt, &out)
if out.FailureCount == 0 {
if len(out.Failed) == 0 {
emitDraftSendOutput(rt, &out)
return nil
}
return output.Errorf(output.ExitAPI, "partial_failure",
"%d of %d drafts failed to send", out.FailureCount, out.Total)
return emitDraftSendPartialFailure(rt, &out)
}
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
@@ -212,7 +227,7 @@ func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
if len(draftIDs) == 0 {
return nil, output.ErrValidation("--draft-id is required")
return nil, mailValidationParamError("--draft-id", "--draft-id is required")
}
normalized := make([]string, 0, len(draftIDs))
@@ -220,16 +235,16 @@ func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
for _, id := range draftIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return nil, output.ErrValidation("--draft-id contains empty value")
return nil, mailValidationParamError("--draft-id", "--draft-id contains empty value")
}
if _, ok := seen[trimmed]; ok {
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
return nil, mailValidationParamError("--draft-id", "--draft-id contains duplicate value: %s", trimmed)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) > MaxBatchSendDrafts {
return nil, output.ErrValidation(
return nil, mailValidationParamError("--draft-id",
"too many drafts: %d > %d (split into multiple batches)",
len(normalized), MaxBatchSendDrafts)
}
@@ -246,6 +261,24 @@ func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
rt.Out(*out, nil)
}
func emitDraftSendPartialFailure(rt *common.RuntimeContext, out *batchSendOutput) error {
out.SuccessCount = len(out.Sent)
out.FailureCount = len(out.Failed)
return rt.OutPartialFailure(*out, nil)
}
// emitDraftSendAborted emits the batch ledger as the single failure result for
// an account-level abort: the ledger carries aborted/abort_error and the
// returned partial-failure signal sets the exit code without a second error
// envelope on stderr.
func emitDraftSendAborted(rt *common.RuntimeContext, out *batchSendOutput, cause error) error {
out.Aborted = true
if typed, ok := errs.UnwrapTypedError(errs.WrapInternal(cause)); ok {
out.AbortError = typed
}
return emitDraftSendPartialFailure(rt, out)
}
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
return
@@ -259,52 +292,38 @@ func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...i
//
// Trigger conditions:
//
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
// - err does not expose a typed Problem:
// unknown shapes are treated as fatal so they cannot accidentally
// accumulate into failed[] for every remaining draft.
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
// "rate_limit", "network"}: token, scope, app-installation problems,
// throttling, and connectivity are account-level.
// - Code == output.ExitNetwork: connectivity loss is account-level.
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
// exhaustion is account-level.
// - Problem.Category ∈ {authentication, authorization, config, network,
// internal}: token, scope, app-installation problems, throttling,
// connectivity, SDK, and invalid-response failures are account-level.
// - Problem.Subtype ∈ {rate_limit, quota_exceeded}: throttling and quota
// exhaustion are account-level.
// - Problem.Code ∈ {1234013, 1236007, 1236008, 1236009, 1236010, 1236013}:
// mailbox missing / quota exhaustion is account-level. Mailbox-not-found
// stays code-scoped (1234013) rather than matching subtype not_found, so
// an unrelated not_found — e.g. a single bad draft ID — remains a
// per-draft recoverable failure.
func isFatalSendErr(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return true
}
switch exitErr.Detail.Type {
case "auth", "app_status", "config":
return true
case "permission", "rate_limit", "network":
switch p.Category {
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig, errs.CategoryNetwork, errs.CategoryInternal:
return true
}
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
if p.Subtype == errs.SubtypeRateLimit || p.Subtype == errs.SubtypeQuotaExceeded {
return true
}
switch exitErr.Detail.Code {
case output.LarkErrMailboxNotFound,
output.LarkErrMailSendQuotaUser,
output.LarkErrMailSendQuotaUserExt,
output.LarkErrMailSendQuotaTenantExt,
output.LarkErrMailQuota,
output.LarkErrTenantStorageLimit:
switch p.Code {
case 1234013, 1236007, 1236008, 1236009, 1236010, 1236013:
return true
}
return false
}
func wrapsExitCode(err error, code int) bool {
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
return true
}
}
return false
}
// extractAutomationDisabledReason returns the human-readable reason when the
// send succeeded at HTTP level (code: 0) but the backend reports that
// automation send is disabled for this mailbox. An empty return value means

View File

@@ -4,6 +4,7 @@
package mail
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -12,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -91,6 +93,32 @@ func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]inter
return stub
}
func decodeDraftSendPartialEnvelopeData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if envelope.OK {
t.Fatalf("expected ok:false partial-failure output, stdout=%s", stdout.String())
}
return envelope.Data
}
func assertPartialFailureSignal(t *testing.T, err error) {
t.Helper()
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("PartialFailureError.Code = %d, want ExitAPI=%d", pfErr.Code, output.ExitAPI)
}
}
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
// and exit code = 0 (err == nil).
@@ -190,7 +218,8 @@ func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
if strings.Contains(stdout.String(), "mail +draft-send:") {
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
}
data := decodeShortcutEnvelopeData(t, stdout)
assertPartialFailureSignal(t, err)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
t.Errorf("unexpected aggregate counts: %#v", data)
}
@@ -198,7 +227,7 @@ func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
// failure does not abort the batch; the remaining drafts are attempted; both
// arrays are populated; and the call returns ExitAPI/"partial_failure".
// arrays are populated; and the call returns the multi-status partial-failure signal.
func TestMailDraftSend_PartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
@@ -225,18 +254,9 @@ func TestMailDraftSend_PartialFailure(t *testing.T) {
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
}
assertPartialFailureSignal(t, err)
data := decodeShortcutEnvelopeData(t, stdout)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
@@ -284,7 +304,8 @@ func TestMailDraftSend_StopOnError(t *testing.T) {
t.Fatal("expected partial_failure error, got nil")
}
data := decodeShortcutEnvelopeData(t, stdout)
assertPartialFailureSignal(t, err)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
@@ -294,6 +315,14 @@ func TestMailDraftSend_StopOnError(t *testing.T) {
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
// A --stop-on-error stop is a caller choice over a draft-level failure,
// not an account-level abort: the aborted/abort_error fields stay unset.
if _, present := data["aborted"]; present {
t.Errorf("aborted should be unset for --stop-on-error, got %v", data["aborted"])
}
if _, present := data["abort_error"]; present {
t.Errorf("abort_error should be unset for --stop-on-error, got %v", data["abort_error"])
}
}
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
@@ -315,12 +344,12 @@ func TestMailDraftSend_FatalAborts(t *testing.T) {
if err == nil {
t.Fatal("expected fatal abort error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
if p.Code != output.LarkErrMailboxNotFound {
t.Errorf("expected code = %d, got %#v", output.LarkErrMailboxNotFound, p)
}
// No JSON envelope on stdout because Execute returned early before rt.Out.
if stdout.Len() != 0 {
@@ -329,9 +358,10 @@ func TestMailDraftSend_FatalAborts(t *testing.T) {
}
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
// after earlier side effects still emits the aggregate stdout ledger before
// returning the fatal stderr error. This lets callers avoid blindly retrying a
// draft that was already sent.
// after earlier side effects emits the aborted stdout ledger as the single
// failure result: the returned partial-failure signal sets the exit code
// without a second error envelope, and abort_error carries the typed cause so
// callers can avoid blindly retrying a draft that was already sent.
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
@@ -349,17 +379,17 @@ func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected fatal abort error, got nil")
t.Fatal("expected partial-failure abort signal, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
// The ledger is the single failure result: the returned error must be the
// envelope-less partial-failure signal, not a typed error that the root
// dispatcher would render as a second failure envelope on stderr.
assertPartialFailureSignal(t, err)
if _, ok := errs.ProblemOf(err); ok {
t.Fatalf("abort signal must not carry a typed problem, got %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
@@ -375,6 +405,19 @@ func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
t.Errorf("failed[0].draft_id = %q, want d2", got)
}
if data["aborted"] != true {
t.Errorf("aborted = %v, want true", data["aborted"])
}
abortErr, ok := data["abort_error"].(map[string]interface{})
if !ok {
t.Fatalf("abort_error = %v, want object", data["abort_error"])
}
if abortErr["type"] != "api" {
t.Errorf("abort_error.type = %v, want api", abortErr["type"])
}
if abortErr["code"].(float64) != float64(output.LarkErrMailSendQuotaUser) {
t.Errorf("abort_error.code = %v, want %d", abortErr["code"], output.LarkErrMailSendQuotaUser)
}
}
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
@@ -402,24 +445,22 @@ func TestMailDraftSend_AutomationDisabled(t *testing.T) {
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %#v, want validation/failed_precondition", p)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
if !strings.Contains(p.Message, "outbound automation disabled") {
t.Errorf("error message should propagate reason, got %q", p.Message)
}
}
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
// automation-send policy stop after earlier successful sends still writes the
// batch ledger to stdout before returning the structured fatal error.
// automation-send policy stop after earlier successful sends emits the aborted
// batch ledger as the single failure result, with the failed-precondition
// cause carried in abort_error.
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
@@ -442,17 +483,27 @@ func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
t.Fatal("expected partial-failure abort signal, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
assertPartialFailureSignal(t, err)
if _, ok := errs.ProblemOf(err); ok {
t.Fatalf("abort signal must not carry a typed problem, got %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
data := decodeDraftSendPartialEnvelopeData(t, stdout)
if data["aborted"] != true {
t.Errorf("aborted = %v, want true", data["aborted"])
}
abortErr, ok := data["abort_error"].(map[string]interface{})
if !ok {
t.Fatalf("abort_error = %v, want object", data["abort_error"])
}
if abortErr["type"] != "validation" || abortErr["subtype"] != "failed_precondition" {
t.Errorf("abort_error type/subtype = %v/%v, want validation/failed_precondition", abortErr["type"], abortErr["subtype"])
}
if msg, _ := abortErr["message"].(string); !strings.Contains(msg, "outbound automation disabled") {
t.Errorf("abort_error.message should carry the reason, got %q", msg)
}
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
@@ -600,12 +651,8 @@ func TestMailDraftSend_MissingYes(t *testing.T) {
if err == nil {
t.Fatal("expected ExitConfirmationRequired, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", code, output.ExitConfirmationRequired)
}
}
@@ -788,93 +835,58 @@ func TestIsFatalSendErr(t *testing.T) {
want: true,
},
{
name: "ExitError without Detail → fatal",
err: &output.ExitError{Code: output.ExitInternal},
name: "internal typed fallback → fatal",
err: errs.NewInternalError(errs.SubtypeSDKError, "unexpected shape"),
want: true,
},
{
name: "auth → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
},
name: "authentication → fatal",
err: errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired"),
want: true,
},
{
name: "app_status → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
},
name: "authorization → fatal",
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "denied"),
want: true,
},
{
name: "config → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
},
err: errs.NewConfigError(errs.SubtypeInvalidConfig, "bad app_id"),
want: true,
},
{
name: "permission → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
},
name: "network → fatal",
err: errs.NewNetworkError(errs.SubtypeNetworkTransport, "DNS timeout"),
want: true,
},
{
name: "rate_limit → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
},
want: true,
},
{
name: "ExitNetwork → fatal",
err: &output.ExitError{
Code: output.ExitNetwork,
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
},
want: true,
},
{
name: "wrapped ExitNetwork → fatal",
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limited").WithCode(output.LarkErrRateLimit),
want: true,
},
{
name: "LarkErrMailboxNotFound → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
},
err: errs.NewAPIError(errs.SubtypeNotFound, "mailbox not found").WithCode(output.LarkErrMailboxNotFound),
want: true,
},
{
name: "LarkErrMailSendQuotaUser → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
},
err: errs.NewAPIError(errs.SubtypeQuotaExceeded, "user daily send count exceeded").WithCode(output.LarkErrMailSendQuotaUser),
want: true,
},
{
name: "LarkErrTenantStorageLimit → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
},
err: errs.NewAPIError(errs.SubtypeQuotaExceeded, "tenant storage limit").WithCode(output.LarkErrTenantStorageLimit),
want: true,
},
{
name: "generic api_error → recoverable",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
},
name: "generic api unknown → recoverable",
err: errs.NewAPIError(errs.SubtypeUnknown, "draft not found").WithCode(230001),
want: false,
},
{
name: "not_found without account-level code → recoverable",
err: errs.NewAPIError(errs.SubtypeNotFound, "draft not found").WithCode(230002),
want: false,
},
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func mailValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func mailValidationParamError(param, format string, args ...any) *errs.ValidationError {
return mailValidationError(format, args...).WithParam(param)
}
func mailInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: name, Reason: reason}
}
func mailFailedPreconditionError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
}
func mailInvalidResponseError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}
func mailFileIOError(format string, err error, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
func mailInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return mailValidationError("unsafe file path: %s", err).WithCause(err)
}
return mailValidationError("cannot read file: %s", err).WithCause(err)
}
func mailDecorateProblemMessage(err error, format string, args ...any) error {
if err == nil {
return nil
}
prefix := fmt.Sprintf(format, args...)
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(prefix) != "" {
p.Message = prefix + ": " + p.Message
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %s", prefix, err.Error()).WithCause(err)
}
func mailAppendProblemHint(err error, hint string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "; " + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewAPIError(errs.SubtypeUnknown, "%s", err.Error()).WithHint("%s", hint).WithCause(err)
}

View File

@@ -0,0 +1,307 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestMailFileIOErrorTyped(t *testing.T) {
cause := errors.New("disk read failed")
err := mailFileIOError("load %s: %v", cause, "body.html", cause)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected internal error, got %T", err)
}
if !errors.Is(err, cause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFileIO)
}
if !strings.Contains(p.Message, "body.html") || !strings.Contains(p.Message, "disk read failed") {
t.Fatalf("message missing context: %q", p.Message)
}
}
func TestMailFileIOErrorDoesNotAppendCauseAsFormatArg(t *testing.T) {
cause := errors.New("mkdir denied")
err := mailFileIOError("cannot create output directory %q", cause, "out")
if !errors.Is(err, cause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if strings.Contains(p.Message, "%!(") {
t.Fatalf("message contains fmt extra marker: %q", p.Message)
}
if strings.Contains(p.Message, "mkdir denied") {
t.Fatalf("cause should not be implicitly appended to message: %q", p.Message)
}
}
func TestMailInputStatErrorTyped(t *testing.T) {
if err := mailInputStatError(nil); err != nil {
t.Fatalf("nil input should stay nil, got %v", err)
}
pathErr := fileio.ErrPathValidation
err := mailInputStatError(pathErr)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if !errors.Is(err, pathErr) {
t.Fatalf("cause not preserved: %v", err)
}
if !strings.Contains(err.Error(), "unsafe file path") {
t.Fatalf("unexpected path validation message: %v", err)
}
statErr := errors.New("permission denied")
err = mailInputStatError(statErr)
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if !errors.Is(err, statErr) {
t.Fatalf("stat cause not preserved: %v", err)
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("unexpected stat message: %v", err)
}
}
func TestMailDecorateProblemMessageTypedAndPlain(t *testing.T) {
if err := mailDecorateProblemMessage(nil, "fetch profile"); err != nil {
t.Fatalf("nil input should stay nil, got %v", err)
}
typedErr := errs.NewAPIError(errs.SubtypeRateLimit, "too many requests")
err := mailDecorateProblemMessage(typedErr, "fetch %s", "profile")
if err != typedErr {
t.Fatalf("typed error should be decorated in place")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Message != "fetch profile: too many requests" {
t.Fatalf("message = %q", p.Message)
}
blankPrefixErr := errs.NewAPIError(errs.SubtypeUnknown, "unchanged")
err = mailDecorateProblemMessage(blankPrefixErr, " ")
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Message != "unchanged" {
t.Fatalf("blank prefix should not change message, got %q", p.Message)
}
plainCause := errors.New("sdk failed")
err = mailDecorateProblemMessage(plainCause, "fetch mailbox")
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("plain error should be upgraded to internal SDK error, got %T", err)
}
if !errors.Is(err, plainCause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeSDKError || !strings.Contains(p.Message, "fetch mailbox: sdk failed") {
t.Fatalf("unexpected problem: %+v", p)
}
}
func TestMailAppendProblemHintTypedAndPlain(t *testing.T) {
if err := mailAppendProblemHint(nil, "retry later"); err != nil {
t.Fatalf("nil input should stay nil, got %v", err)
}
withoutHint := errs.NewAPIError(errs.SubtypeUnknown, "failed")
err := mailAppendProblemHint(withoutHint, "retry later")
if err != withoutHint {
t.Fatalf("typed error should be updated in place")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Hint != "retry later" {
t.Fatalf("hint = %q", p.Hint)
}
withHint := errs.NewAPIError(errs.SubtypeUnknown, "failed").WithHint("check scope")
err = mailAppendProblemHint(withHint, "retry later")
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Hint != "check scope; retry later" {
t.Fatalf("hint = %q", p.Hint)
}
plainCause := errors.New("legacy api failed")
err = mailAppendProblemHint(plainCause, "retry later")
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("plain error should be upgraded to API error, got %T", err)
}
if !errors.Is(err, plainCause) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok = errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Hint != "retry later" || p.Subtype != errs.SubtypeUnknown {
t.Fatalf("unexpected problem: %+v", p)
}
}
func TestValidateBodyFileMutexTypedErrors(t *testing.T) {
err := validateBodyFileMutex("<p>Hello</p>", "body.html", func(string) error { return nil })
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
}
if validationErr.Params[0].Name != "--body" || validationErr.Params[1].Name != "--body-file" {
t.Fatalf("unexpected params: %#v", validationErr.Params)
}
pathErr := errors.New("outside cwd")
err = validateBodyFileMutex("", "body.html", func(string) error { return pathErr })
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T", err)
}
if validationErr.Param != "--body-file" {
t.Fatalf("param = %q, want --body-file", validationErr.Param)
}
if !errors.Is(err, pathErr) {
t.Fatalf("cause not preserved: %v", err)
}
}
func TestReadBodyFileTypedErrors(t *testing.T) {
openErr := errors.New("missing")
_, err := readBodyFile(bodyFileTestIO{
open: func(string) (fileio.File, error) { return nil, openErr },
}, "missing.html")
requireBodyFileValidationError(t, err, openErr)
if !strings.Contains(err.Error(), "open --body-file missing.html") {
t.Fatalf("unexpected open message: %v", err)
}
readErr := errors.New("read broken")
_, err = readBodyFile(bodyFileTestIO{
open: func(string) (fileio.File, error) {
return &bodyFileTestFile{readErr: readErr}, nil
},
}, "body.html")
requireBodyFileValidationError(t, err, readErr)
if !strings.Contains(err.Error(), "read --body-file body.html") {
t.Fatalf("unexpected read message: %v", err)
}
_, err = readBodyFile(bodyFileTestIO{
open: func(string) (fileio.File, error) {
return &bodyFileTestFile{remaining: maxBodyFileSize + 1}, nil
},
}, "huge.html")
requireBodyFileValidationError(t, err, nil)
if !strings.Contains(err.Error(), "file exceeds 32 MB limit") {
t.Fatalf("unexpected size message: %v", err)
}
}
func requireBodyFileValidationError(t *testing.T, err error, cause error) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
if validationErr.Param != "--body-file" {
t.Fatalf("param = %q, want --body-file", validationErr.Param)
}
if cause != nil && !errors.Is(err, cause) {
t.Fatalf("cause %v not preserved in %v", cause, err)
}
}
type bodyFileTestIO struct {
open func(string) (fileio.File, error)
}
func (fio bodyFileTestIO) Open(name string) (fileio.File, error) {
return fio.open(name)
}
func (fio bodyFileTestIO) Stat(string) (fileio.FileInfo, error) {
return nil, errors.New("unused")
}
func (fio bodyFileTestIO) ResolvePath(path string) (string, error) {
return path, nil
}
func (fio bodyFileTestIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, errors.New("unused")
}
type bodyFileTestFile struct {
readErr error
remaining int
}
func (f *bodyFileTestFile) Read(p []byte) (int, error) {
if f.readErr != nil {
return 0, f.readErr
}
if f.remaining <= 0 {
return 0, io.EOF
}
n := len(p)
if n > f.remaining {
n = f.remaining
}
for i := range p[:n] {
p[i] = 'x'
}
f.remaining -= n
return n, nil
}
func (f *bodyFileTestFile) ReadAt([]byte, int64) (int, error) {
return 0, errors.New("unused")
}
func (f *bodyFileTestFile) Close() error {
return nil
}
var _ fileio.FileIO = bodyFileTestIO{}
var _ fileio.File = (*bodyFileTestFile)(nil)

View File

@@ -10,7 +10,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -135,10 +135,10 @@ var MailForward = common.Shortcut{
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
return mailDecorateProblemMessage(err, "failed to fetch original message")
}
if err := validateForwardAttachmentURLs(sourceMsg); err != nil {
return fmt.Errorf("forward blocked: %w", err)
return mailDecorateProblemMessage(err, "forward blocked")
}
orig := sourceMsg.Original
@@ -243,7 +243,7 @@ var MailForward = common.Shortcut{
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
@@ -257,7 +257,7 @@ var MailForward = common.Shortcut{
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("forward blocked: %w", err)
return mailDecorateProblemMessage(err, "forward blocked")
}
processedBody := buildBodyDiv(body, bodyIsHTML(body))
origLargeAttCard := stripLargeAttachmentCard(&orig)
@@ -274,7 +274,7 @@ var MailForward = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody)
if resolveErr != nil {
return resolveErr
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
}
bodyWithSig := resolved
if sigResult != nil {
@@ -347,7 +347,7 @@ var MailForward = common.Shortcut{
}
content, err := downloadAttachmentContent(runtime, att.DownloadURL)
if err != nil {
return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err)
return mailDecorateProblemMessage(err, "failed to download original attachment %s", att.Filename)
}
contentType := att.ContentType
if contentType == "" {
@@ -381,13 +381,13 @@ var MailForward = common.Shortcut{
}
for _, f := range userFiles {
if f.Size > MaxLargeAttachmentSize {
return output.ErrValidation("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
}
totalCount := len(origAtts) + len(largeAttIDs) + len(userFiles)
if totalCount > MaxAttachmentCount {
return output.ErrValidation("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
return mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount)
}
allFiles = append(allFiles, userFiles...)
classified := classifyAttachments(allFiles, emlBase)
@@ -413,7 +413,7 @@ var MailForward = common.Shortcut{
// Upload oversized attachments as large attachments.
if len(classified.Oversized) > 0 {
if composedHTMLBody == "" && composedTextBody == "" {
return output.ErrValidation("large attachments require a body; " +
return mailFailedPreconditionError("large attachments require a body; " +
"empty messages cannot include the download link")
}
if runtime.Config == nil || runtime.UserOpenId() == "" {
@@ -421,7 +421,7 @@ var MailForward = common.Shortcut{
for _, f := range classified.Oversized {
totalBytes += f.Size
}
return output.ErrValidation("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
return mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+
"large attachment upload requires user identity (--as user)",
float64(totalBytes)/1024/1024)
}
@@ -486,18 +486,18 @@ var MailForward = common.Shortcut{
if len(mergedLargeAttIDs) > 0 {
idsJSON, err := json.Marshal(mergedLargeAttIDs)
if err != nil {
return fmt.Errorf("failed to encode large attachment IDs: %w", err)
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err)
}
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON))
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)
return mailValidationError("failed to build EML: %v", err).WithCause(err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
return mailDecorateProblemMessage(err, "failed to create draft")
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -510,7 +510,7 @@ var MailForward = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
return mailDecorateProblemMessage(err, "failed to send forward (draft %s created but not sent)", draftResult.DraftID)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/lint"
@@ -54,10 +55,18 @@ var MailLintHTML = common.Shortcut{
// Mutual exclusion + exactly-one-of validation for --body / --body-file.
bodyEmpty := strings.TrimSpace(body) == ""
if bodyEmpty && bodyFile == "" {
return output.ErrValidation("exactly one of --body or --body-file is required")
return mailValidationError("exactly one of --body or --body-file is required").
WithParams(
mailInvalidParam("--body", "required when --body-file is empty"),
mailInvalidParam("--body-file", "required when --body is empty"),
)
}
if !bodyEmpty && bodyFile != "" {
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
WithParams(
mailInvalidParam("--body", "mutually exclusive with --body-file"),
mailInvalidParam("--body-file", "mutually exclusive with --body"),
)
}
// --body-file safety: cwd-subtree only. Mirrors the existing pattern
@@ -65,7 +74,7 @@ var MailLintHTML = common.Shortcut{
// runtime.ValidatePath.
if bodyFile != "" {
if err := runtime.ValidatePath(bodyFile); err != nil {
return output.ErrValidation("--body-file: %v", err)
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
}
}
@@ -141,7 +150,7 @@ func readLintHTMLBody(runtime *common.RuntimeContext) (string, error) {
path := strings.TrimSpace(runtime.Str("body-file"))
if path == "" {
// Should be unreachable given Validate, but defensive.
return "", output.ErrValidation("internal: --body-file empty after Validate")
return "", errs.NewInternalError(errs.SubtypeUnknown, "internal: --body-file empty after Validate")
}
return readBodyFile(runtime.FileIO(), path)
}

View File

@@ -5,7 +5,6 @@ package mail
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -48,7 +47,7 @@ var MailMessage = common.Shortcut{
msg, err := fetchFullMessage(runtime, mailboxID, messageID, html)
if err != nil {
return fmt.Errorf("failed to fetch email: %w", err)
return mailDecorateProblemMessage(err, "failed to fetch email")
}
out := buildMessageOutput(msg, html)

View File

@@ -137,7 +137,7 @@ var MailReply = common.Shortcut{
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
return mailDecorateProblemMessage(err, "failed to fetch original message")
}
orig := sourceMsg.Original
stripLargeAttachmentCard(&orig)
@@ -213,7 +213,7 @@ var MailReply = common.Shortcut{
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
var bodyStr string
if useHTML {
@@ -264,7 +264,7 @@ var MailReply = common.Shortcut{
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("HTML reply blocked: %w", err)
return mailDecorateProblemMessage(err, "HTML reply blocked")
}
var srcCIDs []string
bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
@@ -273,7 +273,7 @@ var MailReply = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
if resolveErr != nil {
return resolveErr
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
}
bodyWithSig := resolved
if sigResult != nil {
@@ -336,12 +336,12 @@ var MailReply = common.Shortcut{
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)
return mailValidationError("failed to build EML: %v", err).WithCause(err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
return mailDecorateProblemMessage(err, "failed to create draft")
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -354,7 +354,7 @@ var MailReply = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
return mailDecorateProblemMessage(err, "failed to send reply (draft %s created but not sent)", draftResult.DraftID)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -139,7 +139,7 @@ var MailReplyAll = common.Shortcut{
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
return mailDecorateProblemMessage(err, "failed to fetch original message")
}
orig := sourceMsg.Original
stripLargeAttachmentCard(&orig)
@@ -226,7 +226,7 @@ var MailReplyAll = common.Shortcut{
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
var bodyStr string
if useHTML {
@@ -271,7 +271,7 @@ var MailReplyAll = common.Shortcut{
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("HTML reply-all blocked: %w", err)
return mailDecorateProblemMessage(err, "HTML reply-all blocked")
}
var srcCIDs []string
bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
@@ -280,7 +280,7 @@ var MailReplyAll = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
if resolveErr != nil {
return resolveErr
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
}
bodyWithSig := resolved
if sigResult != nil {
@@ -341,12 +341,12 @@ var MailReplyAll = common.Shortcut{
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)
return mailValidationError("failed to build EML: %v", err).WithCause(err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
return mailDecorateProblemMessage(err, "failed to create draft")
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -359,7 +359,7 @@ var MailReplyAll = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err)
return mailDecorateProblemMessage(err, "failed to send reply-all (draft %s created but not sent)", draftResult.DraftID)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -83,7 +82,7 @@ var MailSend = common.Shortcut{
return err
}
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
}
// With --template-id, tos/ccs/bccs may come from the template, so
// defer the at-least-one-recipient check to Execute (after
@@ -241,7 +240,7 @@ var MailSend = common.Shortcut{
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return resolveErr
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
}
resolved = injectSignatureIntoBody(resolved, sigResult)
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
@@ -308,12 +307,12 @@ var MailSend = common.Shortcut{
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)
return mailValidationError("failed to build EML: %v", err).WithCause(err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
return mailDecorateProblemMessage(err, "failed to create draft")
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
@@ -326,7 +325,7 @@ var MailSend = common.Shortcut{
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime)
if err != nil {
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
return mailDecorateProblemMessage(err, "failed to send email (draft %s created but not sent)", draftResult.DraftID)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)

View File

@@ -113,10 +113,11 @@ var MailSendReceipt = common.Shortcut{
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
return mailDecorateProblemMessage(err, "failed to fetch original message")
}
if !hasReadReceiptRequestLabel(msg) {
return fmt.Errorf("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel)
return mailFailedPreconditionError("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel).
WithHint("only run +send-receipt for incoming messages that carry the READ_RECEIPT_REQUEST label")
}
origSubject := strVal(msg["subject"])
@@ -126,12 +127,12 @@ var MailSendReceipt = common.Shortcut{
origSendMillis := parseInternalDateMillis(msg["internal_date"])
if origFromEmail == "" {
return fmt.Errorf("original message %s has no sender address; cannot address receipt", messageID)
return mailFailedPreconditionError("original message %s has no sender address; cannot address receipt", messageID)
}
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return fmt.Errorf("unable to determine sender email; please specify --from explicitly")
return mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
}
lang := detectSubjectLang(origSubject)
@@ -158,16 +159,16 @@ var MailSendReceipt = common.Shortcut{
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build receipt EML: %w", err)
return mailValidationError("failed to build receipt EML: %v", err).WithCause(err)
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create receipt draft: %w", err)
return mailDecorateProblemMessage(err, "failed to create receipt draft")
}
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, "")
if err != nil {
return fmt.Errorf("failed to send receipt (draft %s created but not sent): %w", draftResult.DraftID, err)
return mailDecorateProblemMessage(err, "failed to send receipt (draft %s created but not sent)", draftResult.DraftID)
}
out := buildDraftSendOutput(resData, mailboxID)

View File

@@ -5,9 +5,7 @@ package mail
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -66,14 +64,22 @@ var MailShareToChat = common.Shortcut{
msgID := runtime.Str("message-id")
threadID := runtime.Str("thread-id")
if msgID == "" && threadID == "" {
return output.ErrValidation("either --message-id or --thread-id is required")
return mailValidationError("either --message-id or --thread-id is required").
WithParams(
mailInvalidParam("--message-id", "required when --thread-id is empty"),
mailInvalidParam("--thread-id", "required when --message-id is empty"),
)
}
if msgID != "" && threadID != "" {
return output.ErrValidation("--message-id and --thread-id are mutually exclusive")
return mailValidationError("--message-id and --thread-id are mutually exclusive").
WithParams(
mailInvalidParam("--message-id", "mutually exclusive with --thread-id"),
mailInvalidParam("--thread-id", "mutually exclusive with --message-id"),
)
}
idType := runtime.Str("receive-id-type")
if !validReceiveIDTypes[idType] {
return output.ErrValidation("--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
return mailValidationParamError("--receive-id-type", "--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
}
return nil
},
@@ -90,23 +96,23 @@ var MailShareToChat = common.Shortcut{
} else {
createBody = map[string]interface{}{"message_id": msgID}
}
createResp, err := runtime.CallAPI("POST",
createResp, err := runtime.CallAPITyped("POST",
mailboxPath(mailboxID, "messages", "share_token"),
nil, createBody)
if err != nil {
return fmt.Errorf("create share token: %w", err)
return mailDecorateProblemMessage(err, "create share token")
}
cardID, _ := createResp["card_id"].(string)
if cardID == "" {
return fmt.Errorf("create share token: response missing card_id")
return mailInvalidResponseError("create share token: response missing card_id")
}
sendResp, err := runtime.CallAPI("POST",
sendResp, err := runtime.CallAPITyped("POST",
mailboxPath(mailboxID, "share_tokens", cardID, "send"),
map[string]interface{}{"receive_id_type": receiveIDType},
map[string]interface{}{"receive_id": receiveID})
if err != nil {
return fmt.Errorf("share token created (card_id=%s) but send failed: %w", cardID, err)
return mailDecorateProblemMessage(err, "share token created (card_id=%s) but send failed", cardID)
}
runtime.Out(map[string]interface{}{

View File

@@ -16,16 +16,13 @@ import (
// assertValidationError fails the test unless err carries the validation
// category with ExitValidation exit code and a message containing wantSubstr.
// Accepts both typed *errs.ValidationError and legacy *output.ExitError so
// the helper survives the error-contract migration.
// Mail-produced validation errors should be typed; the exit-code fallback keeps
// shared framework validation gates covered without asserting their shape here.
func assertValidationError(t *testing.T, err error, wantSubstr string) {
t.Helper()
if err == nil {
t.Fatal("expected a validation error, got nil")
}
// Accept both typed *errs.ValidationError and legacy *output.ExitError —
// the helper's purpose is to assert "this is a validation-category
// error" via either contract, so the dual-path matches the docstring.
code := output.ExitCodeOf(err)
if !errs.IsValidation(err) && code != output.ExitValidation {
t.Fatalf("expected a validation-category error, got %T: %v", err, err)
@@ -190,6 +187,10 @@ func validMessageIDForTest(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s))
}
func rawMessageIDForTest(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
if err != nil {
@@ -197,6 +198,13 @@ func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
}
}
func TestValidateMessageIDsAcceptsRawBase64URLIDs(t *testing.T) {
_, err := validateMessageIDs(rawMessageIDForTest("biz-raw-001"))
if err != nil {
t.Fatalf("expected nil error for raw base64url ID, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
_, err := validateMessageIDs("")
assertValidationError(t, err, "--message-ids is required")

View File

@@ -110,7 +110,7 @@ func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetS
}
}
if sig == nil {
return output.ErrValidation("signature not found: %s", sigID)
return mailValidationParamError("--detail", "signature not found: %s", sigID)
}
lang := resolveLang(runtime)

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -79,13 +78,17 @@ var MailTemplateCreate = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("name")) == "" {
return output.ErrValidation("--name is required")
return mailValidationParamError("--name", "--name is required")
}
if len([]rune(runtime.Str("name"))) > 100 {
return output.ErrValidation("--name must be at most 100 characters")
return mailValidationParamError("--name", "--name must be at most 100 characters")
}
if runtime.Str("template-content") != "" && runtime.Str("template-content-file") != "" {
return output.ErrValidation("--template-content and --template-content-file are mutually exclusive")
return mailValidationError("--template-content and --template-content-file are mutually exclusive").
WithParams(
mailInvalidParam("--template-content", "mutually exclusive with --template-content-file"),
mailInvalidParam("--template-content-file", "mutually exclusive with --template-content"),
)
}
return nil
},
@@ -104,7 +107,7 @@ var MailTemplateCreate = common.Shortcut{
content = wrapTemplateContentIfNeeded(content, isPlainText)
if int64(len(content)) > maxTemplateContentBytes {
return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
return mailFailedPreconditionError("template content exceeds %d MB (got %.1f MB)",
maxTemplateContentBytes/(1024*1024),
float64(len(content))/1024/1024)
}
@@ -142,7 +145,7 @@ var MailTemplateCreate = common.Shortcut{
resp, err := createTemplate(runtime, mailboxID, payload)
if err != nil {
return fmt.Errorf("create template failed: %w", err)
return mailDecorateProblemMessage(err, "create template failed")
}
tpl, _ := extractTemplatePayload(resp)
out := map[string]interface{}{
@@ -173,12 +176,12 @@ func resolveTemplateContent(runtime *common.RuntimeContext) (content, sourcePath
}
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", path, output.ErrValidation("open --template-content-file %s: %v", path, err)
return "", path, mailValidationParamError("--template-content-file", "open --template-content-file %s: %v", path, err).WithCause(mailInputStatError(err))
}
defer f.Close()
buf, err := io.ReadAll(f)
if err != nil {
return "", path, output.ErrValidation("read --template-content-file %s: %v", path, err)
return "", path, mailValidationParamError("--template-content-file", "read --template-content-file %s: %v", path, err).WithCause(err)
}
return string(buf), path, nil
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -93,13 +92,17 @@ var MailTemplateUpdate = common.Shortcut{
return err
}
if runtime.Str("template-id") == "" {
return output.ErrValidation("--template-id is required (or use --print-patch-template to print the patch skeleton)")
return mailValidationParamError("--template-id", "--template-id is required (or use --print-patch-template to print the patch skeleton)")
}
if runtime.Str("set-template-content") != "" && runtime.Str("set-template-content-file") != "" {
return output.ErrValidation("--set-template-content and --set-template-content-file are mutually exclusive")
return mailValidationError("--set-template-content and --set-template-content-file are mutually exclusive").
WithParams(
mailInvalidParam("--set-template-content", "mutually exclusive with --set-template-content-file"),
mailInvalidParam("--set-template-content-file", "mutually exclusive with --set-template-content"),
)
}
if name := runtime.Str("set-name"); name != "" && len([]rune(name)) > 100 {
return output.ErrValidation("--set-name must be at most 100 characters")
return mailValidationParamError("--set-name", "--set-name must be at most 100 characters")
}
return nil
},
@@ -171,16 +174,16 @@ var MailTemplateUpdate = common.Shortcut{
if pf := strings.TrimSpace(runtime.Str("patch-file")); pf != "" {
f, err := runtime.FileIO().Open(pf)
if err != nil {
return output.ErrValidation("open --patch-file %s: %v", pf, err)
return mailValidationParamError("--patch-file", "open --patch-file %s: %v", pf, err).WithCause(mailInputStatError(err))
}
buf, readErr := io.ReadAll(f)
f.Close()
if readErr != nil {
return output.ErrValidation("read --patch-file %s: %v", pf, readErr)
return mailValidationParamError("--patch-file", "read --patch-file %s: %v", pf, readErr).WithCause(readErr)
}
var patch templatePatchFile
if err := json.Unmarshal(buf, &patch); err != nil {
return output.ErrValidation("parse --patch-file %s: %v", pf, err)
return mailValidationParamError("--patch-file", "parse --patch-file %s: %v", pf, err).WithCause(err)
}
if patch.TemplateContent != nil {
contentChanged = true
@@ -198,7 +201,7 @@ var MailTemplateUpdate = common.Shortcut{
tpl.TemplateContent = wrapTemplateContentIfNeeded(tpl.TemplateContent, tpl.IsPlainTextMode)
}
if int64(len(tpl.TemplateContent)) > maxTemplateContentBytes {
return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
return mailFailedPreconditionError("template content exceeds %d MB (got %.1f MB)",
maxTemplateContentBytes/(1024*1024),
float64(len(tpl.TemplateContent))/1024/1024)
}
@@ -278,7 +281,7 @@ var MailTemplateUpdate = common.Shortcut{
resp, err := updateTemplate(runtime, mailboxID, tid, tpl)
if err != nil {
return fmt.Errorf("update template failed: %w", err)
return mailDecorateProblemMessage(err, "update template failed")
}
updated, _ := extractTemplatePayload(resp)
out := map[string]interface{}{
@@ -312,12 +315,12 @@ func resolveTemplateUpdateContent(runtime *common.RuntimeContext) (content, sour
}
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", path, output.ErrValidation("open --set-template-content-file %s: %v", path, err)
return "", path, mailValidationParamError("--set-template-content-file", "open --set-template-content-file %s: %v", path, err).WithCause(mailInputStatError(err))
}
defer f.Close()
buf, err := io.ReadAll(f)
if err != nil {
return "", path, output.ErrValidation("read --set-template-content-file %s: %v", path, err)
return "", path, mailValidationParamError("--set-template-content-file", "read --set-template-content-file %s: %v", path, err).WithCause(err)
}
return string(buf), path, nil
}

View File

@@ -5,7 +5,6 @@ package mail
import (
"context"
"fmt"
"sort"
"strconv"
@@ -88,9 +87,9 @@ var MailThread = common.Shortcut{
if runtime.Bool("include-spam-trash") {
params["include_spam_trash"] = true
}
listData, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
listData, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
if err != nil {
return fmt.Errorf("failed to get thread: %w", err)
return mailDecorateProblemMessage(err, "failed to get thread")
}
// New API: data.thread.messages[]; fallback to old API: data.items[].message
var items []interface{}

View File

@@ -4,7 +4,6 @@
package mail
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -13,6 +12,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -140,7 +141,7 @@ var MailTriage = common.Shortcut{
outFormat := runtime.Str("format")
query := runtime.Str("query")
if query != "" {
if err := common.RejectDangerousChars("--query", query); err != nil {
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
return err
}
}
@@ -265,10 +266,18 @@ var MailTriage = common.Shortcut{
messages = []map[string]interface{}{}
}
// Inject mailbox_id into every message so downstream consumers
// (e.g. mail +message) can preserve the mailbox context for
// public/shared mailbox scenarios.
for _, msg := range messages {
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,
@@ -287,6 +296,9 @@ var MailTriage = common.Shortcut{
"subject": sanitizeForTerminal(strVal(msg["subject"])),
"message_id": msg["message_id"],
}
if mailbox != "me" {
row["mailbox_id"] = mailbox
}
if showLabels {
row["labels"] = msg["labels"]
}
@@ -297,6 +309,9 @@ var MailTriage = common.Shortcut{
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))
}
@@ -306,7 +321,11 @@ var MailTriage = common.Shortcut{
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
if mailbox != "me" {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
}
}
return nil
},
@@ -403,9 +422,9 @@ func parseTriageFilter(filterStr string) (triageFilter, error) {
dec.DisallowUnknownFields()
if err := dec.Decode(&filter); err != nil {
if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" {
return triageFilter{}, output.ErrValidation("invalid --filter: %s", hint)
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint)
}
return triageFilter{}, output.ErrValidation("invalid --filter: %s", err)
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", err)
}
return filter, nil
}
@@ -924,16 +943,16 @@ func resolveTriagePath(parsed triagePageToken, query string, filter triageFilter
switch parsed.Path {
case "search":
if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) {
return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token")
return false, mailValidationParamError("--page-token", "--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token")
}
return true, nil
case "list":
if paramWantsSearch {
return false, fmt.Errorf("--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored remove them or use a search: token")
return false, mailValidationParamError("--page-token", "--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored; remove them or use a search: token")
}
return false, nil
default:
return false, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
return false, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
}
}
@@ -960,15 +979,15 @@ func parseTriagePageToken(token string) (triagePageToken, error) {
}
idx := strings.IndexByte(token, ':')
if idx < 0 {
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
}
path := token[:idx]
raw := token[idx+1:]
if path != "search" && path != "list" {
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
}
if raw == "" {
return triagePageToken{}, fmt.Errorf("invalid --page-token: token value is empty after '%s:' prefix", path)
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: token value is empty after '%s:' prefix", path)
}
return triagePageToken{Path: path, RawToken: raw}, nil
}
@@ -1090,24 +1109,18 @@ func doJSONAPI(runtime *common.RuntimeContext, req *larkcore.ApiReq, action stri
var lastErr error
for attempt := 0; attempt <= triageAPIRetries; attempt++ {
apiResp, err := runtime.DoAPI(req)
if err == nil {
var result interface{}
dec := json.NewDecoder(bytes.NewReader(apiResp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: response parse error: %s", action, err)
if err != nil {
lastErr = mailDecorateProblemMessage(client.WrapDoAPIError(err), "%s", action)
if attempt == triageAPIRetries {
return nil, lastErr
}
data, handleErr := common.HandleApiResult(result, nil, action)
} else {
data, handleErr := runtime.ClassifyAPIResponse(apiResp)
if handleErr == nil {
return data, nil
}
lastErr = handleErr
if !shouldRetryTriageAPIError(handleErr) || attempt == triageAPIRetries {
return nil, handleErr
}
} else {
lastErr = output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
if attempt == triageAPIRetries {
lastErr = mailDecorateProblemMessage(handleErr, "%s", action)
if !shouldRetryTriageAPIError(lastErr) || attempt == triageAPIRetries {
return nil, lastErr
}
}
@@ -1117,11 +1130,11 @@ func doJSONAPI(runtime *common.RuntimeContext, req *larkcore.ApiReq, action stri
}
func shouldRetryTriageAPIError(err error) bool {
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return exitErr.Detail.Type == "rate_limit" || exitErr.Code == output.ExitNetwork
return p.Subtype == errs.SubtypeRateLimit || p.Category == errs.CategoryNetwork
}
func toQueryParams(params map[string]interface{}) larkcore.QueryParams {

View File

@@ -7,10 +7,13 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -119,6 +122,36 @@ func TestBuildSearchParamsSystemLabelAsFolder(t *testing.T) {
}
}
func TestMailTriageRejectsDangerousQueryWithTypedValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--as", "user", "--query", "bad\x01",
}, f, stdout)
if err == nil {
t.Fatal("expected dangerous --query to return an 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 validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--query" {
t.Errorf("param = %q, want --query", validationErr.Param)
}
if !strings.Contains(p.Message, "control character") {
t.Errorf("message should mention control character, got: %s", p.Message)
}
}
func TestSystemLabelViaFolderField(t *testing.T) {
// System label passed via folder field should also be converted to search folder value.
runtime := runtimeForMailTriageTest(t, map[string]string{
@@ -706,6 +739,43 @@ func TestFormatAddressFallbackToAddress(t *testing.T) {
}
}
func TestShouldRetryTriageAPIError(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{
name: "rate limit",
err: errs.NewAPIError(errs.SubtypeRateLimit, "too many requests"),
want: true,
},
{
name: "network",
err: errs.NewNetworkError(errs.SubtypeNetworkTransport, "dial timeout"),
want: true,
},
{
name: "validation",
err: errs.NewValidationError(errs.SubtypeInvalidArgument, "bad query"),
want: false,
},
{
name: "plain",
err: assertErr("legacy plain error"),
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shouldRetryTriageAPIError(tc.err); got != tc.want {
t.Fatalf("shouldRetryTriageAPIError() = %v, want %v", got, tc.want)
}
})
}
}
// --- extractTriageMessageIDs ---
func TestExtractTriageMessageIDsStringItems(t *testing.T) {
@@ -1405,3 +1475,279 @@ func TestParseTriagePageTokenInvalidPrefix(t *testing.T) {
}
func boolPtr(v bool) *bool { return &v }
// --- mailbox_id preservation tests ---
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
tests := []struct {
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
}{
{
name: "list json default mailbox",
mailbox: "me",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageListStub(reg, mailbox, []string{"msg_001", "msg_002"}, false, "")
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
mailTriageBatchMessage("msg_001", "Subject 1"),
mailTriageBatchMessage("msg_002", "Subject 2"),
})
},
wantCount: 2,
},
{
name: "list data public mailbox",
mailbox: "shared@company.com",
format: "data",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageListStub(reg, mailbox, []string{"msg_pub_001"}, false, "")
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
mailTriageBatchMessage("msg_pub_001", "Shared mailbox message"),
})
},
wantCount: 1,
},
{
name: "search json public mailbox",
mailbox: "shared@corp.com",
format: "json",
args: []string{"--query", "shared keyword"},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageSearchStub(reg, mailbox, []interface{}{
mailTriageSearchItem("search_pub_001", "Shared search"),
}, false, "")
},
wantCount: 1,
},
{
name: "empty list json keeps top-level mailbox",
mailbox: "me",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageListStub(reg, mailbox, nil, false, "")
},
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
tt.register(reg, tt.mailbox)
args := []string{"+triage", "--format", tt.format}
if tt.mailbox != "me" {
args = append(args, "--mailbox", tt.mailbox)
}
args = append(args, tt.args...)
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
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)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
}
for i, msg := range messages {
if msg["mailbox_id"] != tt.mailbox {
t.Fatalf("message[%d] mailbox_id mismatch: got %v, want %q", i, msg["mailbox_id"], tt.mailbox)
}
}
})
}
}
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", []string{"msg_ok", "msg_missing"}, false, "")
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
mailTriageBatchMessage("msg_ok", "Present"),
})
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--format", "json",
"--filter", `{"folder_id":"INBOX"}`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
messages := mailTriageMessagesFromOutput(t, decodeMailTriageJSONOutput(t, stdout))
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
for i, msg := range messages {
if msg["mailbox_id"] != "me" {
t.Fatalf("message[%d] mailbox_id mismatch: got %v, want me", i, msg["mailbox_id"])
}
}
if messages[1]["message_id"] != "msg_missing" || messages[1]["error"] == nil {
t.Fatalf("missing metadata placeholder mismatch: %#v", messages[1])
}
}
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
name string
mailbox string
hasMore bool
wantMailboxColumn bool
wantMailboxHint bool
}{
{name: "default mailbox", mailbox: "me"},
{name: "public mailbox", mailbox: "shared@company.com", hasMore: true, wantMailboxColumn: true, wantMailboxHint: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, tt.mailbox, []string{"msg_001"}, tt.hasMore, "next_page_token")
registerMailTriageBatchStub(reg, tt.mailbox, []map[string]interface{}{
mailTriageBatchMessage("msg_001", "Table message"),
})
args := []string{"+triage", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
if tt.mailbox != "me" {
args = append(args, "--mailbox", tt.mailbox)
}
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if got := strings.Contains(out, "mailbox_id"); got != tt.wantMailboxColumn {
t.Fatalf("mailbox_id column presence mismatch: got %v, want %v\nstdout:\n%s", got, tt.wantMailboxColumn, out)
}
if tt.wantMailboxColumn && !strings.Contains(out, tt.mailbox) {
t.Fatalf("table output should contain mailbox %q, stdout:\n%s", tt.mailbox, out)
}
errOut := stderr.String()
quotedMailbox := shellQuote(tt.mailbox)
if got := strings.Contains(errOut, "--mailbox "+quotedMailbox); got != tt.wantMailboxHint {
t.Fatalf("mailbox hint presence mismatch: got %v, want %v\nstderr:\n%s", got, tt.wantMailboxHint, errOut)
}
if !strings.Contains(errOut, "mail +message") {
t.Fatalf("stderr should contain mail +message tip, got:\n%s", errOut)
}
})
}
}
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
t.Helper()
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
return data
}
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
rawMessages, ok := data["messages"].([]interface{})
if !ok {
t.Fatalf("messages type mismatch: %T", data["messages"])
}
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)
}
messages = append(messages, msg)
}
return messages
}
func registerMailTriageListStub(reg *httpmock.Registry, mailbox string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
}
if pageToken != "" {
data["page_token"] = pageToken
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: mailboxPath(mailbox, "messages") + "?",
Body: map[string]interface{}{
"code": 0,
"data": data,
},
})
}
func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, messages []map[string]interface{}) {
rawMessages := make([]interface{}, 0, len(messages))
for _, msg := range messages {
rawMessages = append(rawMessages, msg)
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: mailboxPath(mailbox, "messages", "batch_get"),
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": rawMessages,
},
},
})
}
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
}
if pageToken != "" {
data["page_token"] = pageToken
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: mailboxPath(mailbox, "search"),
Body: map[string]interface{}{
"code": 0,
"data": data,
},
})
}
func mailTriageBatchMessage(messageID, subject string) map[string]interface{} {
return map[string]interface{}{
"message_id": messageID,
"subject": subject,
"head_from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
"folder_id": "INBOX",
}
}
func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
return map[string]interface{}{
"meta_data": map[string]interface{}{
"message_biz_id": messageID,
"title": subject,
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
},
}
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -23,6 +22,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -187,7 +188,7 @@ var MailWatch = common.Shortcut{
switch outFormat {
case "json", "data", "":
default:
return output.ErrValidation("invalid --format %q: must be json or data", outFormat)
return mailValidationParamError("--format", "invalid --format %q: must be json or data", outFormat)
}
msgFormat := runtime.Str("msg-format")
outputDir := runtime.Str("output-dir")
@@ -196,18 +197,18 @@ var MailWatch = common.Shortcut{
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
return mailValidationParamError("--output-dir", "--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
return mailValidationParamError("--output-dir", "invalid --output-dir %q: %v", outputDir, err).WithCause(err)
}
outputDir = safePath
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
return mailFileIOError("cannot create output directory %q: %v", err, outputDir, err)
}
}
labelIDsInput := runtime.Str("label-ids")
@@ -246,7 +247,7 @@ var MailWatch = common.Shortcut{
// Step 1: subscribe mailbox events (required before WebSocket pushes mail events)
info(fmt.Sprintf("Subscribing mailbox events for: %s", mailbox))
_, err = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "subscribe"), nil, map[string]interface{}{"event_type": 1})
_, err = runtime.CallAPITyped("POST", mailboxPath(mailbox, "event", "subscribe"), nil, map[string]interface{}{"event_type": 1})
if err != nil {
return wrapWatchSubscribeError(err)
}
@@ -256,7 +257,7 @@ var MailWatch = common.Shortcut{
var unsubErr error
unsubscribe := func() error {
unsubOnce.Do(func() {
_, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
_, unsubErr = runtime.CallAPITyped("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
})
return unsubErr
}
@@ -485,7 +486,7 @@ var MailWatch = common.Shortcut{
if watchCtx.Err() != nil {
return nil
}
return output.ErrNetwork("WebSocket connection failed: %v", err)
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "WebSocket connection failed: %v", err).WithCause(err)
}
return nil
}
@@ -508,7 +509,7 @@ func parseJSONArrayFlag(input, flagName string) ([]string, error) {
}
var values []string
if err := json.Unmarshal([]byte(trimmed), &values); err != nil {
return nil, output.ErrValidation("invalid --%s: expected JSON array of strings, e.g. [\"INBOX\",\"SENT\"]", flagName)
return nil, mailValidationParamError("--"+flagName, "invalid --%s: expected JSON array of strings, e.g. [\"INBOX\",\"SENT\"]", flagName).WithCause(err)
}
out := make([]string, 0, len(values))
for _, value := range values {
@@ -712,19 +713,13 @@ func fetchMessageForWatch(runtime *common.RuntimeContext, mailbox, messageID, fo
ApiPath: fmt.Sprintf("/open-apis/mail/v1/user_mailboxes/%s/messages/%s", validate.EncodePathSegment(mailbox), validate.EncodePathSegment(messageID)),
QueryParams: queryParams,
})
if err != nil {
return nil, client.WrapDoAPIError(err)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, err
}
if code, _ := result["code"].(float64); code != 0 {
msg, _ := result["msg"].(string)
return nil, fmt.Errorf("[%.0f] %s", code, msg)
}
data, _ := result["data"].(map[string]interface{})
msg, _ := data["message"].(map[string]interface{})
if msg == nil {
return data, nil
@@ -748,29 +743,40 @@ func wrapWatchSubscribeError(err error) error {
return nil
}
hint := "ensure the app has scope mail:event and the event mail.user_mailbox.event.message_received_v1 is enabled"
if exitErr, ok := err.(*output.ExitError); ok && exitErr.Detail != nil {
msg := "subscribe mailbox events failed: " + exitErr.Detail.Message
if exitErr.Detail.Hint != "" {
hint = exitErr.Detail.Hint + "; " + hint
if p, ok := errs.ProblemOf(err); ok {
p.Message = "subscribe mailbox events failed: " + p.Message
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "; " + hint
} else {
p.Hint = hint
}
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, msg, hint)
return err
}
return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint)
return errs.NewAPIError(errs.SubtypeUnknown, "subscribe mailbox events failed: %v", err).WithHint("%s", hint).WithCause(err)
}
// enhanceProfileError wraps a profile API error with actionable hints.
// Permission errors get a scope-specific hint; other errors (network, 5xx)
// are reported as-is so diagnostics aren't misleading.
func enhanceProfileError(err error) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
errType := exitErr.Detail.Type
lower := strings.ToLower(exitErr.Detail.Message)
if errType == "permission" || errType == "missing_scope" ||
strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
"unable to resolve mailbox address: "+exitErr.Detail.Message,
"run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access")
if p, ok := errs.ProblemOf(err); ok {
lower := strings.ToLower(p.Message)
if p.Category == errs.CategoryAuthorization {
p.Message = "unable to resolve mailbox address: " + p.Message
p.Hint = "run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access"
return err
}
if strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "unable to resolve mailbox address: %s", p.Message).
WithHint("run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access").
WithCause(err)
if p.Code != 0 {
permErr = permErr.WithCode(p.Code)
}
if p.LogID != "" {
permErr = permErr.WithLogID(p.LogID)
}
return permErr
}
}
// Preserve original error (and its exit code) for non-permission failures.

View File

@@ -8,16 +8,19 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -221,6 +224,72 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) {
}
}
func TestMailWatchOutputDirRejectsUnsafePathTyped(t *testing.T) {
chdirTemp(t)
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailWatch, []string{
"+watch",
"--output-dir", "../escape",
}, f, stdout)
if err == nil {
t.Fatal("expected unsafe output-dir error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != "--output-dir" {
t.Fatalf("param = %q, want --output-dir", validationErr.Param)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestMailWatchOutputDirMkdirFailureTyped(t *testing.T) {
chdirTemp(t)
mkdirErr := errors.New("mkdir denied")
f, stdout, _, _ := mailShortcutTestFactory(t)
oldFS := vfs.DefaultFS
vfs.DefaultFS = failingMkdirFS{OsFs: vfs.OsFs{}, err: mkdirErr}
t.Cleanup(func() { vfs.DefaultFS = oldFS })
err := runMountedMailShortcut(t, MailWatch, []string{
"+watch",
"--output-dir", "watch-output",
}, f, stdout)
if err == nil {
t.Fatal("expected mkdir error")
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected internal error, got %T: %v", err, err)
}
if !errors.Is(err, mkdirErr) {
t.Fatalf("cause not preserved: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFileIO)
}
if strings.Contains(p.Message, "%!(") {
t.Fatalf("message contains fmt extra marker: %q", p.Message)
}
if !strings.Contains(p.Message, "cannot create output directory") || !strings.Contains(p.Message, "mkdir denied") {
t.Fatalf("message missing context: %q", p.Message)
}
}
func TestWatchFetchFailureValue(t *testing.T) {
value := watchFetchFailureValue("msg_123", "metadata", assertErr("boom"), map[string]interface{}{
"mail_address": "alice@example.com",
@@ -523,24 +592,93 @@ func TestWrapWatchSubscribeErrorPlain(t *testing.T) {
}
}
func TestWrapWatchSubscribeErrorExitError(t *testing.T) {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Message: "permission denied",
Hint: "check app permissions",
},
}
err := wrapWatchSubscribeError(exitErr)
func TestWrapWatchSubscribeErrorTypedProblem(t *testing.T) {
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").
WithHint("check app permissions")
err := wrapWatchSubscribeError(apiErr)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "subscribe mailbox events failed") {
t.Fatalf("unexpected message: %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("original message missing: %v", err)
if !strings.Contains(p.Message, "subscribe mailbox events failed") {
t.Fatalf("unexpected message: %v", p.Message)
}
if !strings.Contains(p.Message, "permission denied") {
t.Fatalf("original message missing: %v", p.Message)
}
if !strings.Contains(p.Hint, "check app permissions") {
t.Fatalf("original hint missing: %v", p.Hint)
}
}
func TestWrapWatchSubscribeErrorTypedProblemAddsMissingHint(t *testing.T) {
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied")
err := wrapWatchSubscribeError(apiErr)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if !strings.Contains(p.Hint, "mail:event") {
t.Fatalf("scope hint missing: %q", p.Hint)
}
}
func TestEnhanceProfileErrorAuthorization(t *testing.T) {
original := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope")
err := enhanceProfileError(original)
if err != original {
t.Fatalf("authorization error should be updated in place")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if !strings.Contains(p.Message, "unable to resolve mailbox address") {
t.Fatalf("message missing mailbox context: %q", p.Message)
}
if !strings.Contains(p.Hint, "mail:user_mailbox:readonly") {
t.Fatalf("profile scope hint missing: %q", p.Hint)
}
}
func TestEnhanceProfileErrorPermissionMessagePromotesToMissingScope(t *testing.T) {
original := errs.NewAPIError(errs.SubtypeUnknown, "scope denied").
WithCode(99991679).
WithLogID("logid-profile")
err := enhanceProfileError(original)
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) {
t.Fatalf("expected permission error, got %T", err)
}
if !errors.Is(err, original) {
t.Fatalf("original error not preserved as cause: %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeMissingScope {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeMissingScope)
}
if p.Code != 99991679 || p.LogID != "logid-profile" {
t.Fatalf("code/logid not preserved: %+v", p)
}
}
func TestEnhanceProfileErrorPreservesNonPermissionError(t *testing.T) {
original := errs.NewNetworkError(errs.SubtypeNetworkTransport, "dial timeout")
if got := enhanceProfileError(original); got != original {
t.Fatalf("non-permission errors should pass through, got %T", got)
}
}
@@ -687,6 +825,15 @@ type testErr struct{ msg string }
func (e *testErr) Error() string { return e.msg }
type failingMkdirFS struct {
vfs.OsFs
err error
}
func (f failingMkdirFS) MkdirAll(string, fs.FileMode) error {
return f.err
}
type watchDryRunPayload struct {
API []struct {
Method string `json:"method"`

View File

@@ -5,9 +5,9 @@ package signature
import (
"encoding/json"
"fmt"
"net/url"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,19 +27,19 @@ func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesRe
return cached, nil
}
data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil)
data, err := runtime.CallAPITyped("GET", signaturesPath(mailboxID), nil, nil)
if err != nil {
return nil, fmt.Errorf("get signatures: %w", err)
return nil, err
}
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("get signatures: marshal response: %w", err)
return nil, errs.NewInternalError(errs.SubtypeSDKError, "get signatures: marshal response: %v", err).WithCause(err)
}
var resp GetSignaturesResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("get signatures: unmarshal response: %w", err)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "get signatures: unmarshal response: %v", err).WithCause(err)
}
processCache[mailboxID] = &resp
@@ -66,5 +66,5 @@ func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signat
return &resp.Signatures[i], nil
}
}
return nil, fmt.Errorf("signature not found: %s", signatureID)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "signature not found: %s", signatureID)
}

View File

@@ -5,7 +5,6 @@ package mail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
@@ -13,7 +12,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -33,8 +32,6 @@ type signatureResult struct {
Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// Returns nil if signatureID is empty.
// resolveSignature fetches, interpolates, and downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
@@ -62,7 +59,7 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err)
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
@@ -110,13 +107,12 @@ func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) e
return bld
}
// resolveSenderInfo fetches senderName and senderEmail via the send_as API.
// resolveSenderInfo fetches send_as addresses and returns the name/email
// for signature interpolation. If fromEmail is non-empty, it matches
// that address in the sendable list (for alias/send_as scenarios);
// otherwise falls back to the first (primary) address.
func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) {
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
if err != nil {
return "", ""
}
@@ -155,45 +151,54 @@ func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail stri
func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err)
return nil, "", mailInvalidResponseError("signature image download: invalid URL: %v", err).WithCause(err)
}
if u.Scheme != "https" {
return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme)
return nil, "", mailInvalidResponseError("signature image download: URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, "", fmt.Errorf("signature image download: URL has no host")
return nil, "", mailInvalidResponseError("signature image download: URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
}
ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
}
// Do NOT send Authorization: the download URL is pre-signed.
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: %v", err).WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body))
if resp.StatusCode >= 500 {
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkServer, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return nil, "", errs.NewAPIError(subtype, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).WithCode(resp.StatusCode)
}
const maxSize = 10 * 1024 * 1024
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, "", fmt.Errorf("signature image download: read body: %w", err)
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: read body: %v", err).WithCause(err)
}
if len(data) > maxSize {
return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit")
return nil, "", mailFailedPreconditionError("signature image download: file exceeds 10MB limit")
}
ct := resp.Header.Get("Content-Type")
@@ -242,7 +247,11 @@ func signatureCIDs(sig *signatureResult) []string {
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return output.ErrValidation("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
return mailValidationError("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode").
WithParams(
mailInvalidParam("--plain-text", "mutually exclusive with --signature-id"),
mailInvalidParam("--signature-id", "requires HTML mode"),
)
}
return nil
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{})
cases := []struct {
name string
url string
}{
{name: "invalid", url: "https://[::1"},
{name: "http", url: "http://example.com/sig.png"},
{name: "no host", url: "https:///sig.png"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := downloadSignatureImage(rt, tc.url, "sig.png")
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected internal error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidResponse)
}
})
}
}
func TestDownloadSignatureImageHTTPErrorClassification(t *testing.T) {
for _, tc := range []struct {
name string
statusCode int
wantType any
wantSub errs.Subtype
retryable bool
}{
{
name: "server",
statusCode: http.StatusInternalServerError,
wantType: (*errs.NetworkError)(nil),
wantSub: errs.SubtypeNetworkServer,
retryable: true,
},
{
name: "not found",
statusCode: http.StatusNotFound,
wantType: (*errs.APIError)(nil),
wantSub: errs.SubtypeNotFound,
},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "download failed", tc.statusCode)
}))
t.Cleanup(srv.Close)
rt := newDownloadRuntime(t, srv.Client())
_, _, err := downloadSignatureImage(rt, srv.URL+"/sig.png", "sig.png")
switch tc.wantType.(type) {
case *errs.NetworkError:
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("expected network error, got %T (%v)", err, err)
}
case *errs.APIError:
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected API error, got %T (%v)", err, err)
}
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Code != tc.statusCode {
t.Fatalf("code = %d, want %d", p.Code, tc.statusCode)
}
if p.Subtype != tc.wantSub {
t.Fatalf("subtype = %q, want %q", p.Subtype, tc.wantSub)
}
if p.Retryable != tc.retryable {
t.Fatalf("retryable = %v, want %v", p.Retryable, tc.retryable)
}
})
}
}
func TestDownloadSignatureImageReadAndSizeErrors(t *testing.T) {
readErr := errors.New("socket closed")
rt := newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: signatureErrorBody{err: readErr},
Request: req,
}, nil
}),
})
_, _, err := downloadSignatureImage(rt, "https://example.com/sig.png", "sig.png")
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("expected network error, got %T (%v)", err, err)
}
if !errors.Is(err, readErr) {
t.Fatalf("read cause not preserved: %v", err)
}
rt = newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: &bodyFileTestFile{remaining: 10*1024*1024 + 1},
Request: req,
}, nil
}),
})
_, _, err = downloadSignatureImage(rt, "https://example.com/huge.png", "huge.png")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestDownloadSignatureImageSuccessUsesFilenameContentType(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("gif-data")),
Request: req,
}, nil
}),
})
data, contentType, err := downloadSignatureImage(rt, "https://example.com/sig.gif", "sig.gif")
if err != nil {
t.Fatalf("downloadSignatureImage failed: %v", err)
}
if string(data) != "gif-data" {
t.Fatalf("data = %q", string(data))
}
if contentType != "image/gif" {
t.Fatalf("content type = %q, want image/gif", contentType)
}
}
func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
err := validateSignatureWithPlainText(true, "sig_123")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
}
if validationErr.Params[0].Name != "--plain-text" || validationErr.Params[1].Name != "--signature-id" {
t.Fatalf("unexpected params: %#v", validationErr.Params)
}
}
type signatureRoundTripper func(*http.Request) (*http.Response, error)
func (rt signatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt(req)
}
type signatureErrorBody struct {
err error
}
func (b signatureErrorBody) Read([]byte) (int, error) {
return 0, b.err
}
func (b signatureErrorBody) Close() error {
return nil
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/google/uuid"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -222,7 +222,7 @@ func validateTemplateID(tid string) error {
return nil
}
if _, err := strconv.ParseInt(tid, 10, 64); err != nil {
return output.ErrValidation("--template-id must be a decimal integer string")
return mailValidationParamError("--template-id", "--template-id must be a decimal integer string")
}
return nil
}
@@ -264,7 +264,7 @@ func joinTemplateAddresses(addrs []templateMailAddr) string {
func generateTemplateCID() (string, error) {
id, err := uuid.NewRandom()
if err != nil {
return "", fmt.Errorf("failed to generate CID: %w", err)
return "", errs.NewInternalError(errs.SubtypeSDKError, "failed to generate CID: %v", err).WithCause(err)
}
return id.String(), nil
}
@@ -276,23 +276,23 @@ func generateTemplateCID() (string, error) {
func uploadToDriveForTemplate(ctx context.Context, runtime *common.RuntimeContext, path string) (fileKey string, size int64, err error) {
info, err := runtime.FileIO().Stat(path)
if err != nil {
return "", 0, fmt.Errorf("failed to stat %s: %w", path, err)
return "", 0, mailInputStatError(err)
}
size = info.Size()
if size > MaxLargeAttachmentSize {
return "", size, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
return "", size, mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit",
filepath.Base(path), float64(size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024)
}
name := filepath.Base(path)
if err := filecheck.CheckBlockedExtension(name); err != nil {
return "", size, err
return "", size, mailValidationError("%v", err).WithCause(err)
}
userOpenId := runtime.UserOpenId()
if userOpenId == "" {
return "", size, fmt.Errorf("template attachment upload requires user identity (--as user)")
return "", size, mailFailedPreconditionError("template attachment upload requires user identity (--as user)")
}
if size <= common.MaxDriveMediaUploadSinglePartSize {
fileKey, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
fileKey, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: path,
FileName: name,
FileSize: size,
@@ -300,7 +300,7 @@ func uploadToDriveForTemplate(ctx context.Context, runtime *common.RuntimeContex
ParentNode: &userOpenId,
})
} else {
fileKey, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
fileKey, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: path,
FileName: name,
FileSize: size,
@@ -309,7 +309,7 @@ func uploadToDriveForTemplate(ctx context.Context, runtime *common.RuntimeContex
})
}
if err != nil {
return "", size, fmt.Errorf("upload %s to Drive failed: %w", name, err)
return "", size, mailDecorateProblemMessage(err, "upload %s to Drive failed", name)
}
return fileKey, size, nil
}
@@ -404,7 +404,7 @@ func (b *templateAttachmentBuilder) append(fileKey, filename, cid string, isInli
// self-healing via the LARGE switch inside append().
func (b *templateAttachmentBuilder) finalize() error {
if b.rawBodyInlineSmall > maxTemplateBodyInlineSmallBytes {
return fmt.Errorf("template body + inline images exceed %d MB (got %.1f MB); "+
return mailFailedPreconditionError("template body + inline images exceed %d MB (got %.1f MB); "+
"reduce inline image size or count — inline images cannot be promoted to LARGE",
maxTemplateBodyInlineSmallBytes/(1024*1024),
float64(b.rawBodyInlineSmall)/1024/1024)
@@ -511,9 +511,9 @@ func replaceImgSrcOnce(html, rawSrc, newSrc string) string {
// fetchTemplate GETs a single template (full fields) for --template-id
// composition and update patch workflows.
func fetchTemplate(runtime *common.RuntimeContext, mailboxID, templateID string) (*templatePayload, error) {
data, err := runtime.CallAPI("GET", templateMailboxPath(mailboxID, templateID), nil, nil)
data, err := runtime.CallAPITyped("GET", templateMailboxPath(mailboxID, templateID), nil, nil)
if err != nil {
return nil, fmt.Errorf("fetch template %s failed: %w", templateID, err)
return nil, mailDecorateProblemMessage(err, "fetch template %s failed", templateID)
}
return extractTemplatePayload(data)
}
@@ -526,29 +526,29 @@ func extractTemplatePayload(data map[string]interface{}) (*templatePayload, erro
raw = t
}
if raw == nil {
return nil, fmt.Errorf("API response missing template body")
return nil, mailInvalidResponseError("API response missing template body")
}
buf, err := json.Marshal(raw)
if err != nil {
return nil, fmt.Errorf("re-encode template payload failed: %w", err)
return nil, errs.NewInternalError(errs.SubtypeSDKError, "re-encode template payload failed: %v", err).WithCause(err)
}
var out templatePayload
if err := json.Unmarshal(buf, &out); err != nil {
return nil, fmt.Errorf("decode template payload failed: %w", err)
return nil, mailInvalidResponseError("decode template payload failed: %v", err).WithCause(err)
}
return &out, nil
}
// createTemplate POSTs a new template.
func createTemplate(runtime *common.RuntimeContext, mailboxID string, tpl *templatePayload) (map[string]interface{}, error) {
return runtime.CallAPI("POST", templateMailboxPath(mailboxID), nil, map[string]interface{}{
return runtime.CallAPITyped("POST", templateMailboxPath(mailboxID), nil, map[string]interface{}{
"template": tpl,
})
}
// updateTemplate PUTs a full-replace update.
func updateTemplate(runtime *common.RuntimeContext, mailboxID, templateID string, tpl *templatePayload) (map[string]interface{}, error) {
return runtime.CallAPI("PUT", templateMailboxPath(mailboxID, templateID), nil, map[string]interface{}{
return runtime.CallAPITyped("PUT", templateMailboxPath(mailboxID, templateID), nil, map[string]interface{}{
"template": tpl,
})
}
@@ -889,9 +889,9 @@ func fetchTemplateAttachmentURLs(
}
apiURL := templateMailboxPath(mailboxID, templateID) + "/attachments/download_url?" + strings.Join(parts, "&")
data, err := runtime.CallAPI("GET", apiURL, nil, nil)
data, err := runtime.CallAPITyped("GET", apiURL, nil, nil)
if err != nil {
return nil, warnings, fmt.Errorf("template attachments/download_url (template_id=%s): %w", templateID, err)
return nil, warnings, mailDecorateProblemMessage(err, "template attachments/download_url (template_id=%s)", templateID)
}
if urls, ok := data["download_urls"].([]interface{}); ok {
for _, item := range urls {
@@ -976,11 +976,11 @@ func embedTemplateInlineAttachments(
for _, ref := range wanted {
dlURL, ok := urlMap[ref.FileKey]
if !ok || dlURL == "" {
return bld, nil, fmt.Errorf("template inline image %q (cid=%s): download URL not returned by server", ref.Filename, ref.CID)
return bld, nil, mailInvalidResponseError("template inline image %q (cid=%s): download URL not returned by server", ref.Filename, ref.CID)
}
bytes, err := downloadAttachmentContent(runtime, dlURL)
if err != nil {
return bld, nil, fmt.Errorf("template inline image %q (cid=%s): %w", ref.Filename, ref.CID, err)
return bld, nil, mailDecorateProblemMessage(err, "template inline image %q (cid=%s)", ref.Filename, ref.CID)
}
filename := ref.Filename
if filename == "" {
@@ -988,7 +988,7 @@ func embedTemplateInlineAttachments(
}
contentType, err := filecheck.CheckInlineImageFormat(filename, bytes)
if err != nil {
return bld, nil, fmt.Errorf("template inline image %q (cid=%s): %w", filename, ref.CID, err)
return bld, nil, mailValidationError("template inline image %q (cid=%s): %v", filename, ref.CID, err).WithCause(err)
}
bld = bld.AddInline(bytes, contentType, filename, ref.CID)
registered = append(registered, ref.CID)
@@ -1037,11 +1037,11 @@ func embedTemplateSmallAttachments(
}
dlURL, ok := urlMap[ref.FileKey]
if !ok || dlURL == "" {
return bld, 0, fmt.Errorf("template attachment %q: download URL not returned by server", ref.Filename)
return bld, 0, mailInvalidResponseError("template attachment %q: download URL not returned by server", ref.Filename)
}
buf, err := downloadAttachmentContent(runtime, dlURL)
if err != nil {
return bld, 0, fmt.Errorf("template attachment %q: %w", ref.Filename, err)
return bld, 0, mailDecorateProblemMessage(err, "template attachment %q", ref.Filename)
}
filename := ref.Filename
if filename == "" {

View File

@@ -14,6 +14,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -400,17 +401,21 @@ func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
// invoking it on the mail parent (or any of its children) must yield
// a structured *output.ExitError with type "unknown_flag".
// a typed validation problem for the unknown flag.
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T (%v)", got, got)
var validationErr *errs.ValidationError
if !errors.As(got, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", got, got)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "unknown_flag" {
t.Fatalf("expected Detail.Type=unknown_flag, got %#v", exitErr.Detail)
if validationErr.Param != "--bogus" {
t.Fatalf("expected Param=--bogus, got %q", validationErr.Param)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected Code=ExitAPI(%d), got %d", output.ExitAPI, exitErr.Code)
problem, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", got, got)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %s/%s", problem.Category, problem.Subtype)
}
}

View File

@@ -11,5 +11,6 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesScreenshot,
}
}

View File

@@ -0,0 +1,442 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/base64"
"fmt"
"path/filepath"
"regexp"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
// local files. The raw API returns Base64 image payloads; this shortcut keeps
// those payloads out of stdout so agents only see small file metadata.
var SlidesScreenshot = common.Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
// Only wiki URL inputs need wiki:node:read. Keep it conditional so plain
// slides IDs/URLs do not require an unrelated wiki scope.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
{Name: "output-name", Desc: "file name stem for --content render output"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
renderMode := runtime.Changed("content")
if renderMode {
if strings.TrimSpace(runtime.Str("content")) == "" {
return common.FlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.FlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return common.FlagErrorf("--presentation cannot be used with --content")
}
} else {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
return err
}
if !hasSlideScreenshotSelector(runtime) {
return common.FlagErrorf("--slide-id or --slide-number is required")
}
}
if _, err := validateScreenshotOutputDir(runtime.Str("output-dir")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Changed("content") {
return dryRunRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)).
Body(body)
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("content") {
return executeRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return common.FlagErrorf("--slide-id or --slide-number is required")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(outputDir)
if err != nil {
return err
}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)
query := larkcore.QueryParams{}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
data, err := runtime.DoAPIJSONWithLogID("POST", url, query, body)
if err != nil {
return err
}
saved, err := saveSlideScreenshots(data, safeOutputDir)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"xml_presentation_id": presentationID,
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
},
}
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
}
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
Body(map[string]interface{}{
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
})
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
}
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return common.FlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.FlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return common.FlagErrorf("--presentation cannot be used with --content")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(outputDir)
if err != nil {
return err
}
data, err := runtime.DoAPIJSONWithLogID("POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
saved, err := saveRenderedSlideScreenshot(data, safeOutputDir, runtime.Str("output-name"))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
}
func normalizeSlideIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, v := range values {
s := strings.TrimSpace(v)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func normalizeSlideNumbers(values []int) ([]int, error) {
out := make([]int, 0, len(values))
seen := map[int]struct{}{}
for _, n := range values {
if n < 1 {
return nil, common.FlagErrorf("--slide-number must be a positive integer")
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out, nil
}
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
}
func validateScreenshotOutputDir(outputDir string) (string, error) {
safeProbe, err := validate.SafeOutputPath(filepath.Join(outputDir, "probe.png"))
if err != nil {
return "", common.FlagErrorf("--output-dir invalid: %v", err)
}
return filepath.Dir(safeProbe), nil
}
func ensureScreenshotOutputDir(outputDir string) (string, error) {
safeOutputDir, err := validateScreenshotOutputDir(outputDir)
if err != nil {
return "", err
}
if err := vfs.MkdirAll(safeOutputDir, 0o755); err != nil {
return "", output.Errorf(output.ExitAPI, "io_error", "create output directory %s: %v", outputDir, err)
}
return safeOutputDir, nil
}
func saveSlideScreenshots(data map[string]interface{}, outputDir string) ([]map[string]interface{}, error) {
items := common.GetSlice(data, "slide_images")
if len(items) == 0 {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
}
saved := make([]map[string]interface{}, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
}
item, err := saveSlideScreenshotImage(m, outputDir, "", slideScreenshotFallbackName(m, i))
if err != nil {
if _, ok := err.(*output.ExitError); ok {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
}
saved = append(saved, item)
}
return saved, nil
}
func saveRenderedSlideScreenshot(data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
item := common.GetMap(data, "slide_image")
if item == nil {
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
}
saved, err := saveSlideScreenshotImage(item, outputDir, outputName, "rendered-slide")
if err != nil {
if _, ok := err.(*output.ExitError); ok {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
}
return []map[string]interface{}{saved}, nil
}
func saveSlideScreenshotImage(item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
ext, label, err := slideScreenshotFormat(item)
if err != nil {
if slideID != "" {
return nil, fmt.Errorf("%v for slide %s", err, slideID)
}
return nil, err
}
encoded := strings.TrimSpace(common.GetString(item, "data"))
if encoded == "" {
if slideID != "" {
return nil, fmt.Errorf("empty image data for slide %s", slideID)
}
return nil, fmt.Errorf("empty image data")
}
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
if slideID != "" {
return nil, fmt.Errorf("decode screenshot for slide %s: %v", slideID, err)
}
return nil, fmt.Errorf("decode screenshot: %v", err)
}
fileBase := strings.TrimSpace(outputName)
if fileBase == "" {
fileBase = slideID
}
if fileBase == "" {
fileBase = fallbackName
}
path := filepath.Join(outputDir, safeScreenshotFileName(fileBase, ext))
if err := vfs.WriteFile(path, imageBytes, 0o644); err != nil {
return nil, output.Errorf(output.ExitAPI, "io_error", "write screenshot %s: %v", path, err)
}
return map[string]interface{}{
"slide_id": slideID,
"slide_number": common.GetInt(item, "slide_number"),
"format": label,
"path": path,
"size": len(imageBytes),
}, nil
}
func slideScreenshotFallbackName(item map[string]interface{}, index int) string {
if slideNumber := common.GetInt(item, "slide_number"); slideNumber > 0 {
return fmt.Sprintf("slide-%d", slideNumber)
}
return fmt.Sprintf("slide-%d", index+1)
}
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
format := common.GetInt(item, "format")
switch format {
case 1:
return "png", "png", nil
case 2:
return "jpg", "jpeg", nil
default:
return "", "", fmt.Errorf("unsupported screenshot format %d", format)
}
}
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
detail := map[string]interface{}{
"raw_data": summarizeScreenshotAPIData(data),
}
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
detail["log_id"] = logID
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Message: msg,
Detail: detail,
},
}
}
func summarizeScreenshotAPIData(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = summarizeScreenshotAPIData(val)
}
return out
case []interface{}:
out := make([]interface{}, 0, len(x))
for i, val := range x {
if i >= 20 {
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
break
}
out = append(out, summarizeScreenshotAPIData(val))
}
return out
case string:
if len(x) > 512 {
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
}
return x
default:
return x
}
}
func safeScreenshotFileName(base string, ext string) string {
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
name = strings.Trim(name, "._-")
if name == "" {
name = "slide"
}
return name + "." + ext
}

View File

@@ -0,0 +1,403 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
if len(got) != 1 || got[0] != "slides:presentation:screenshot" {
t.Fatalf("declared scopes = %#v, want [slides:presentation:screenshot]", got)
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
imageBytes := []byte("png-bytes")
jpegBytes := []byte("jpeg-bytes")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
{
"slide_id": "slide_2",
"slide_number": 2,
"format": 2,
"data": base64.StdEncoding.EncodeToString(jpegBytes),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--output-dir", "shots",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "shots", "slide_1.png")
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read screenshot: %v", err)
}
if string(gotBytes) != string(imageBytes) {
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
}
jpegPath := filepath.Join(dir, "shots", "slide_2.jpg")
gotJPEGBytes, err := os.ReadFile(jpegPath)
if err != nil {
t.Fatalf("read jpeg screenshot: %v", err)
}
if string(gotJPEGBytes) != string(jpegBytes) {
t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes)
}
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 2 {
t.Fatalf("screenshots = %#v, want two items", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if item["slide_id"] != "slide_1" {
t.Fatalf("slide_id = %v, want slide_1", item["slide_id"])
}
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "slide_1.png")) {
t.Fatalf("path = %v, want shots/slide_1.png suffix", item["path"])
}
item2, _ := items[1].(map[string]interface{})
if item2["format"] != "jpeg" {
t.Fatalf("format = %v, want jpeg", item2["format"])
}
if !strings.HasSuffix(item2["path"].(string), filepath.Join("shots", "slide_2.jpg")) {
t.Fatalf("path = %v, want shots/slide_2.jpg suffix", item2["path"])
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" {
t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs)
}
}
func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_number": 2,
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes")),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideNumbers []int `json:"slide_numbers"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 {
t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers)
}
path := filepath.Join(dir, defaultSlidesScreenshotDir, "slide-2.png")
if _, err := os.ReadFile(path); err != nil {
t.Fatalf("read screenshot without slide_id: %v", err)
}
}
func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") {
t.Fatalf("error = %v, want missing selector error", err)
}
}
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
content := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil {
t.Fatalf("write input xml: %v", err)
}
imageBytes := []byte("rendered-png")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/slide_image/render",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_image": map[string]interface{}{
"slide_id": "render_slide",
"slide_number": 1,
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", "@slide.xml",
"--output-dir", "shots",
"--output-name", "preview",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "shots", "preview.png")
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rendered screenshot: %v", err)
}
if string(gotBytes) != string(imageBytes) {
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
}
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
}
var body struct {
Content string `json:"content"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body.Content != content {
t.Fatalf("content = %q, want input XML", body.Content)
}
data := decodeShortcutData(t, stdout)
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) {
t.Fatalf("path = %v, want shots/preview.png suffix", item["path"])
}
}
func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--slide-id", "slide_1",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
t.Fatalf("error = %v, want content/slide selector conflict", err)
}
}
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--presentation cannot be used with --content") {
t.Fatalf("error = %v, want presentation/content conflict", err)
}
}
func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) {
t.Run("list", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") {
t.Fatalf("dry-run missing list endpoint: %s", out)
}
if !strings.Contains(out, "slide_numbers") {
t.Fatalf("dry-run missing slide_numbers body: %s", out)
}
})
t.Run("render", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/slide_image/render") {
t.Fatalf("dry-run missing render endpoint: %s", out)
}
if !strings.Contains(out, "base64_output") {
t.Fatalf("dry-run missing base64 suppression note: %s", out)
}
})
}
func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--output-dir", "../outside",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for unsafe output dir")
}
if !strings.Contains(err.Error(), "--output-dir invalid") {
t.Fatalf("error = %v, want output-dir validation", err)
}
}
func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Headers: map[string][]string{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"log-123"},
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"unexpected": "shape",
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "pJJ",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want ExitError", err)
}
if exitErr.Detail == nil || exitErr.Detail.Detail == nil {
t.Fatalf("missing error detail: %+v", exitErr)
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("detail = %#v, want map", exitErr.Detail.Detail)
}
if detail["log_id"] != "log-123" {
t.Fatalf("log_id = %v, want log-123", detail["log_id"])
}
raw, ok := detail["raw_data"].(map[string]interface{})
if !ok {
t.Fatalf("raw_data = %#v, want map", detail["raw_data"])
}
if raw["unexpected"] != "shape" {
t.Fatalf("raw_data.unexpected = %v, want shape", raw["unexpected"])
}
}

View File

@@ -0,0 +1,33 @@
## 选哪个命令
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |
| 已知 open_id 取他人资料 | `+search-user --user-ids <id>` | [`+get-user --user-id <id>`](references/lark-contact-get-user.md) |
| 查看自己 | `+get-user``+search-user --user-ids me` | 不支持 |
已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。
## 典型场景
```bash
# 找张三给他发消息:先搜,确认 open_id,再发
lark-cli contact +search-user --query "张三" --has-chatted --as user
lark-cli im +messages-send --user-id ou_xxx --text "Hi!"
```
搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。
## 注意事项
- **41050 / Permission denied** 受当前身份的可见范围限制(两条命令都可能遇到)。换 bot 身份或让管理员调整可见范围,细节见 [`lark-shared`](../lark-shared/SKILL.md)。
- **跨租户用户**(`is_cross_tenant=true`)多数业务字段为空字符串,这是飞书可见性规则,下游做空值兜底。
- **ID 类型**:默认 `open_id``+get-user` 可改 `--user-id-type union_id|user_id`;`+search-user` 只接受 `open_id`
## 不在本 skill 范围
- 发消息 / 查聊天记录 → [`lark-im`](../lark-im/SKILL.md)
- 排日程 / 邀请会议 → [`lark-calendar`](../lark-calendar/SKILL.md)
- 部门树 / 按部门列员工 / 组织架构 → [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) 查找原生接口

View File

@@ -1,14 +1,16 @@
---
name: lark-contact
version: 1.0.0
description: "飞书 / Lark 通讯录,用于按姓名 / 邮箱把员工解析成 open_id,以及按 open_id 反查员工的姓名 / 部门 / 邮箱 / 联系方式。当用户说出某人姓名下一步需要发消息 / 加群 / 排日程时,先用本 skill 把姓名换成 ID;当输出里出现 open_id 需要展示成姓名给用户看,或用户直接询问某人的部门 / 邮箱 / 联系方式时,用本 skill 查。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。"
description: "飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli contact --help"
---
# lark-contact
# contact (v2)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 选哪个命令
@@ -19,17 +21,29 @@ metadata:
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |
| 已知 open_id 取他人资料 | `+search-user --user-ids <id>` | [`+get-user --user-id <id>`](references/lark-contact-get-user.md) |
| 查看自己 | `+get-user``+search-user --user-ids me` | 不支持 |
| 查同事的个人状态 / 签名 | `user_profiles batch_query` | 不支持 |
已知 open_id 只是想发消息 / 排日程,不必经过 contact —— 直接 [`lark-im`](../lark-im/SKILL.md) / [`lark-calendar`](../lark-calendar/SKILL.md)。
## 典型场景
找张三给他发消息:先搜,确认 open_id,再发:
```bash
# 找张三给他发消息:先搜,确认 open_id,再发
lark-cli contact +search-user --query "张三" --has-chatted --as user
lark-cli im +messages-send --user-id ou_xxx --text "Hi!"
```
批量查同事的个人状态 / 个性签名(先用 schema 看参数)。
```bash
lark-cli schema contact.user_profiles.batch_query
lark-cli contact user_profiles batch_query \
--params '{"user_id_type":"open_id"}' \
--data '{"user_ids":["ou_xxx","ou_yyy"],"query_option":{"include_personal_status":true,"include_description":true}}' \
--as user
```
搜索命中多条且后续操作有副作用(发消息、邀请会议等),把候选列给用户挑;不要擅自选第一条。
## 注意事项
@@ -42,4 +56,4 @@ lark-cli im +messages-send --user-id ou_xxx --text "Hi!"
- 发消息 / 查聊天记录 → [`lark-im`](../lark-im/SKILL.md)
- 排日程 / 邀请会议 → [`lark-calendar`](../lark-calendar/SKILL.md)
- 部门树 / 按部门列员工 / 组织架构 ,通过 [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) 查找原生接口
- 部门树 / 按部门列员工 / 组织架构 [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) 查找原生接口

View File

@@ -28,6 +28,8 @@ metadata:
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -266,6 +268,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+preview`](references/lark-drive-preview.md) | List or download available preview artifacts for a Drive file; explicit `--type` required for downloads |
| [`+cover`](references/lark-drive-cover.md) | List or download stable built-in cover presets for a Drive file; download-time HTTP 404 means the file has no artifact for that cover spec |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
@@ -295,12 +299,14 @@ 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 解析脚本。
### files
- `copy` — 复制文件
- `create_folder` — 新建文件夹
- `list` — 获取文件夹下的清单
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
- `patch` — 修改文件标题
### file.comments
@@ -345,31 +351,40 @@ lark-cli drive <resource> <method> [flags] # 调用 API
- `update_reaction` — 添加/删除 reaction
### quota_details
- `get` — 获取当前用户的容量信息,包含各业务使用量、租户配额是否超限、用户配额、所在部门配额
- 仅支持 `--as user`,不要使用默认的 bot 身份
- `quota_detail_id` 传当前用户的 `user_id`
## 权限表
| 方法 | 所需 scope |
|------------------------------------------------|-----------------------------------|
| `files.copy` | `docs:document:copy` |
| `files.create_folder` | `space:folder:create` |
| `files.list` | `space:document:retrieve` |
| `files.patch` | `docx:document:write_only` |
| `file.comments.batch_query` | `docs:document.comment:read` |
| `file.comments.create_v2` | `docs:document.comment:create` |
| `file.comments.list` | `docs:document.comment:read` |
| `file.comments.patch` | `docs:document.comment:update` |
| `file.comment.replys.create` | `docs:document.comment:create` |
| `file.comment.replys.delete` | `docs:document.comment:delete` |
| `file.comment.replys.list` | `docs:document.comment:read` |
| `file.comment.replys.update` | `docs:document.comment:update` |
| `permission.members.auth` | `docs:permission.member:auth` |
| `permission.members.create` | `docs:permission.member:create` |
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
| `permission.public.get` | `docs:permission.setting:read` |
| 方法 | 所需 scope |
|------------------------------------------------|--------------------------------------|
| `files.copy` | `docs:document:copy` |
| `files.create_folder` | `space:folder:create` |
| `files.list` | `space:document:retrieve` |
| `files.patch` | `docx:document:write_only` |
| `file.comments.batch_query` | `docs:document.comment:read` |
| `file.comments.create_v2` | `docs:document.comment:create` |
| `file.comments.list` | `docs:document.comment:read` |
| `file.comments.patch` | `docs:document.comment:update` |
| `file.comment.replys.create` | `docs:document.comment:create` |
| `file.comment.replys.delete` | `docs:document.comment:delete` |
| `file.comment.replys.list` | `docs:document.comment:read` |
| `file.comment.replys.update` | `docs:document.comment:update` |
| `permission.members.auth` | `docs:permission.member:auth` |
| `permission.members.create` | `docs:permission.member:create` |
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
| `permission.public.get` | `docs:permission.setting:read` |
| `permission.public.patch` | `docs:permission.setting:write_only` |
| `metas.batch_query` | `drive:drive.metadata:readonly` |
| `user.remove_subscription` | `docs:event:subscribe` |
| `user.subscription` | `docs:event:subscribe` |
| `user.subscription_status` | `docs:event:subscribe` |
| `file.statistics.get` | `drive:drive.metadata:readonly` |
| `file.view_records.list` | `drive:file:view_record:readonly` |
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
| `metas.batch_query` | `drive:drive.metadata:readonly` |
| `user.remove_subscription` | `docs:event:subscribe` |
| `user.subscription` | `docs:event:subscribe` |
| `user.subscription_status` | `docs:event:subscribe` |
| `file.statistics.get` | `drive:drive.metadata:readonly` |
| `file.view_records.list` | `drive:file:view_record:readonly` |
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
| `quota_details.get` | `drive:quota_detail:read_one` |
> `quota_details.get` 是 user-only OpenAPI调用时必须显式传 `--as user`,且 `quota_detail_id` 应填写当前用户的 `user_id`。

View File

@@ -0,0 +1,79 @@
## `drive +cover`
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
列出或下载 Drive 文件的稳定封面预设。这个 shortcut 只暴露 `spec`,不暴露底层 `cover_option` 细节。
### 命令
```bash
# 列出内置封面规格
lark-cli drive +cover \
--file-token "<FILE_TOKEN>" \
--list-only
# 下载 square 规格封面
lark-cli drive +cover \
--file-token "<FILE_TOKEN>" \
--spec square \
--output ./artifacts/report-cover
# 下载默认大图封面,并在文件冲突时覆盖
lark-cli drive +cover \
--file-token "<FILE_TOKEN>" \
--spec default \
--output ./artifacts/report-cover.png \
--if-exists overwrite
```
### 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | Drive 文件 token |
| `--spec` | 条件必填 | 封面预设:`default` / `icon` / `grid` / `small` / `middle` / `big` / `square` |
| `--version` | 否 | 文件版本号 |
| `--list-only` | 否 | 仅返回可选规格,不下载 |
| `--output` | 条件必填 | 下载到本地的输出路径 |
| `--if-exists` | 否 | 输出冲突策略:`error`(默认)/ `overwrite` / `rename` |
### 输出约定
- 查询态返回:
- `mode=list`
- `file_token`
- `candidates[]`
- `next_action`
- 下载态返回:
- `mode=download`
- `file_token`
- `selected_spec`
- `output_path`
- `status`
### 内置规格
- `default` -- 标准大图封面
- `icon` -- 列表小图标
- `grid` -- 网格/卡片流小封面
- `small` -- PC 小图
- `middle` -- 中等尺寸封面
- `big` -- 偏移动端的大图封面
- `square` -- 正方形裁剪封面
### 关键约束
- 不传 `--list-only` 时,必须显式传 `--spec``--output`
- `drive +cover` 只返回静态预设规格,不伪造后端“可下载状态”
- 不返回底层 `bus_type` / `platform` / `width` / `height` / `policy` 等实现细节
- 下载时直接调用 `preview_download`
- 未显式带扩展名时,会优先根据响应头补扩展名,缺失时回退到 `.png`
### 错误提示
- 下载某个 `--spec` 时如果返回 **HTTP 404**,表示这个文件**没有该规格对应的封面产物**,应视为“该规格不可用”,而不是默认按网络抖动或临时失败处理
### 参考
- [lark-drive](../SKILL.md) -- Drive 总入口
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,158 @@
# drive files list原生 API读取 Drive 文件夹清单)
`drive files list` 是原生 API 命令,不是 shortcut。它用于读取 Drive 根目录或某个 Drive 文件夹的直接子项如果要递归盘点目录树Agent 必须基于返回的子文件夹 token 继续调用本命令。
## 什么时候使用
| 场景 | 是否使用 | 说明 |
|------|----------|------|
| 盘点一个已确认的 Drive 文件夹树 | 使用 | 从目标 `folder_token` 开始递归列取 |
| 盘点用户明确确认的 Drive 根目录 | 使用 | 第一层用空 `folder_token`,子文件夹继续按普通文件夹递归 |
| 验证移动 / 创建后的实际位置 | 使用 | 读取目标目录直接子项,再按需递归验证 |
| 根据关键词、标题、时间、owner 找资源 | 不使用 | 优先用 `drive +search` |
| 读取 Docx 正文内容 | 不使用 | 用 `docs +fetch --api-version v2` |
| 读取 Sheet / Base 内部数据 | 不使用 | 切到 `lark-sheets` / `lark-base` |
## 标准命令模板
读取普通文件夹:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200}' \
--format json
```
继续翻页:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
--format json
```
读取当前用户 Drive 根目录的直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"","page_size":200}' \
--format json
```
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
## 参数规则
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` 结构。
## 返回结构与解析
`--format json` 输出中Agent 只使用 `data` 中符合 `schema drive.files.list` 的 API 返回字段。
常用字段:
| 字段 | 用途 |
|------|------|
| `data.files` | 当前页直接子项列表 |
| `data.has_more` | 当前目录是否还有下一页 |
| `data.next_page_token` | 下一页 token`has_more=true` 时放回 `--params.page_token` |
| `data.files[].type` | 文件类型;等于 `folder` 时可递归 |
| `data.files[].token` | 当前资源 token文件夹递归时作为下一层 `folder_token` |
| `data.files[].name` | 生成路径和展示标题 |
| `data.files[].url` | 资源浏览器链接 |
| `data.files[].owner_id` | 资源所有者 |
| `data.files[].created_time` / `data.files[].modified_time` | 创建 / 更新时间 |
字段名以 `schema drive.files.list` 为准。Agent MUST 以实际返回为准;如果字段缺失,先用 `schema drive.files.list` 或一页样本确认结构,不要猜测。
## 根目录语义
1. `folder_token` 为空字符串或省略时,请求的是当前调用用户的 Drive 根目录直接子项。
2. 根目录返回值不是递归结果;不能把根目录第一页或直接子项数量当作整个云空间资源总量。
3. 根目录只作为目录树起点。返回的子文件夹必须用其自己的 `folder_token` 继续调用 `drive files list`
4. 根据 schema 描述,根目录第一层清单不支持分页且不返回快捷方式;不要基于根目录响应推断子文件夹内容、根目录第一层快捷方式或无法分页的根目录剩余项已经被覆盖。
## 递归盘点规则
1. 只对返回项中的 `folder` 类型继续递归。
2. 每个目录独立维护分页状态;一个目录的 `page_token` 不可复用于其他目录。
3. 对每个目录持续请求,直到返回 `has_more=false`。非根目录的普通文件夹清单可能返回 `type=shortcut` 条目;不要假设这些条目会携带 `shortcut_info` 目标信息。
4. 递归过程中生成稳定 `path`;不要只保存标题,否则同名资源无法区分。
5. URL、owner、创建时间和更新时间优先使用 `files.list` 返回字段;如果字段缺失或需要批量补齐,再使用 `drive metas batch_query`。不要从标题或路径猜元数据。
6. 深度、数量、每目录页数等限制只能作为内部批次 checkpoint不能作为递归完成条件。
7. 达到深度 checkpoint 时,把更深层子文件夹加入 continuation queue并在下一批从这些子文件夹继续保留原始 `path`
8. 达到数量 checkpoint 时,保存当前目录、当前页 token、剩余目录队列和已收集资源计数并立即继续下一批不要进入分析或规划阶段。
### 递归算法
Agent 盘点 Drive 文件夹树时,按以下顺序执行:
1. 初始化待处理队列,放入起点目录:
- 普通文件夹:`{folder_token:"<folder_token>", path:"<folder_name>"}`
- Drive 根目录:`{folder_token:"", path:""}`
2. 从队列取出一个目录,请求第一页。
3.`(folder_token, page_token)` 生成当前页 key同一页 key 只允许追加一次,避免 retry 时重复计数。
4.`data.files` 取当前页直接子项,按 `dedupe_key` 去重后生成 `path` 并加入结果集。
5. 如果新追加的子项是 `folder`,把子文件夹 token、子路径和 depth 加入队列。
6. 如果 `has_more=true`,取 `data.next_page_token` 继续请求同一目录下一页。
7. 同一目录分页结束后,再处理队列中的下一个目录。
8. 如果达到深度、数量或每目录页数 checkpoint把当前目录 / 页 token / 剩余队列 / 已访问页 key / dedupe key 写入 continuation queue并继续下一批。
9. 普通队列和 continuation queue 都为空,且没有分页 blocker 时,才可以认为本次确认范围盘点完成。
简化伪代码:
```text
queue = [root_or_start_folder]
visited_pages = set()
dedupe_keys = set()
while queue not empty:
folder = queue.pop()
page_token = folder.page_token or ""
retry_without_token = 0
while true:
page_key = (folder.folder_token, page_token or "first")
page = drive files list(folder.folder_token, page_token)
if page_key not in visited_pages:
append only files whose dedupe_key is not in dedupe_keys
enqueue newly appended child folders with folder_token, path, and depth
add page_key to visited_pages
if page.has_more != true:
break
next = page.next_page_token
if next is empty:
retry_without_token += 1
if retry_without_token >= 3:
record pagination blocker for folder
break
continue
page_token = next
retry_without_token = 0
```
## 分页与异常
1. 默认手动处理 `has_more` 和返回中的 `next_page_token`
2. 不要使用 `--page-all` 作为脚本 JSON 解析输入;自动翻页输出可能不适合直接 `json.loads`
3. 如果 `has_more=true` 但没有可用的 `next_page_token`,重试同一页最多 3 次。
4. 重试后仍无 continuation token 时,记录受影响的目录和 pagination blocker停止扩展该目录不要无限循环也不要宣称该目录已完整覆盖。
5. 如果触发深度、数量或每目录页数限制,把它视为批处理 checkpoint在确认范围内继续下一批而不是把当前结果说成完整。
6. 不要因为达到 `max_depth=3``max_items=500` 或类似单批阈值就结束盘点;只有队列耗尽或遇到权限 / API / 工具预算 blocker 才能结束当前确认范围的盘点。
## JSON 解析规则
1. stdout 是数据通道。脚本解析 JSON 时只读取 stdout。
2. stderr 可能包含刷新 token、进度、warning 或其他提示;不要把 stderr 合并进 JSON 输入,例如不要用 `2>&1` 后再 `json.loads`
3. 使用 `--format json` 保持 stdout 为结构化 JSON解析 Drive 文件清单时只读取 `data.files` / `data.has_more` / `data.next_page_token` 等 schema 字段。
4. 不要用根目录响应数量或当前页数量推断递归总量;递归总量必须由实际遍历并去重后的资源集合计算。
## 常见错误
| 错误用法 | 问题 | 正确做法 |
|----------|------|----------|
| `lark-cli drive files list --folder-token <token>` | `files.list` 不提供 `--folder-token` flag | 使用 `--params '{"folder_token":"<token>"}'` |
| 根目录返回 N 项就认为云空间只有 N 项 | 根目录只返回直接子项,不是递归结果 | 对返回的子文件夹继续递归 |
| `--page-all \| python json.loads(...)` | 自动翻页输出不适合作为单个 JSON 对象解析 | 手动使用 `page_token` 翻页并逐页解析 |
| `cmd 2>&1` 后解析 JSON | stderr 提示污染 JSON 输入 | 只解析 stdoutstderr 作为日志处理 |

View File

@@ -0,0 +1,87 @@
## `drive +preview`
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
- 只想看候选项时,用 `--list-only`
- 想下载时,必须显式传 `--type``--output`
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
### 命令
```bash
# 列出可用预览候选项
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--list-only
# 下载 PDF 预览
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--type pdf \
--output ./artifacts/report
# 下载文本预览,并在目标已存在时自动改名
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--type text \
--output ./artifacts/report \
--if-exists rename
# 指定版本号查询/下载
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--version "12" \
--type html \
--output ./artifacts/report.html
```
### 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | Drive 文件 token |
| `--type` | 条件必填 | 预览类型;优先使用 `--list-only` 返回的 `type`,如 `pdf` / `html` / `text` / `png` / `jpg` / `source_file` |
| `--version` | 否 | 文件版本号 |
| `--list-only` | 否 | 仅返回候选项,不下载 |
| `--output` | 条件必填 | 下载到本地的输出路径 |
| `--if-exists` | 否 | 输出冲突策略:`error`(默认)/ `overwrite` / `rename` |
### 输出约定
- 查询态返回:
- `mode=list`
- `file_token`
- `candidates[]`
- `next_action`
- 下载态返回:
- `mode=download`
- `file_token`
- `selected_type`
- `output_path`
- `status`
### 候选项字段
`candidates[]` 中每个对象包含:
- `type`
- `type_code`
- `label`
- `status`
- `status_code`
- `downloadable`
- `reason`(可选)
### 关键约束
- 不传 `--list-only` 时,必须显式传 `--type``--output`
- 不会隐式选择“第一个候选项”作为默认下载目标
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名
### 参考
- [lark-drive](../SKILL.md) -- Drive 总入口
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -24,7 +24,8 @@ MUST:
4. Switch to `lark-sheets` / `lark-base` only when sheet / bitable title and path are insufficient.
5. Record read evidence for classification.
6. Continue reading low-confidence resources in internal batches until all supported low-confidence resources in the current inventory are processed or a blocker occurs.
7. Output progress / summary without asking the user to continue between batches.
7. Apply `Analysis Progress Reporting`.
8. Output progress / summary without asking the user to continue between batches.
Exit: low-confidence items are classified or marked `needs_review=true`.
@@ -93,6 +94,30 @@ Output this summary:
- After every 50 processed low-confidence resources.
- Once after low-confidence reading finishes.
- About every 60 seconds during long-running reads, even if fewer than 50 additional resources were processed.
### Analysis Progress Reporting
Applies to `CONTENT_READ`, `ISSUE_ANALYSIS`, and `RULE_GENERATION`.
Rules:
1. For `CONTENT_READ`, use `Low-Confidence Read Summary` as the progress report format.
2. For `ISSUE_ANALYSIS`, if analysis runs longer than about 60 seconds, output progress about every 60 seconds with current stage, processed resource count when known, detected problem type count when known, and the next analysis step.
3. For `RULE_GENERATION`, if classification rule or target-tree generation runs longer than about 60 seconds, output progress about every 60 seconds with current stage, classified item count when known, unresolved item count when known, and target category / path count when known.
4. Progress reports MUST be factual and stage-specific. Do not output generic "still running" messages without counts or the current stage.
5. Do not ask the user to continue between internal batches unless auth, permission, API, target scope, or environment blockers occur.
6. Do not expose internal chain-of-thought, raw tokens, or intermediate rule drafts.
Examples:
```text
分析进度:正在归纳整理问题,已处理 <processed_count>/<resource_count> 项资源,已识别 <problem_type_count> 类问题。继续生成整理思路,不会执行移动或创建。
```
```text
规则生成进度:正在生成分类规则和目标目录,已归类 <classified_count> 项,待人工确认 <needs_review_count> 项。继续生成完整计划前置数据。
```
## State: ISSUE_ANALYSIS
@@ -103,8 +128,9 @@ MUST:
1. Detect problems from organization perspective only. Do not generate research conclusions.
2. Generate an organization approach based on inventory, low-confidence read evidence, and detected problems.
3. Include how non-reused source containers will be handled after their contents are moved.
4. Output `Inventory And Organization Approach Decision`.
5. Stop and wait for the user to confirm the approach before `RULE_GENERATION`.
4. Apply `Analysis Progress Reporting`.
5. Output `Inventory And Organization Approach Decision`.
6. Stop and wait for the user to confirm the approach before `RULE_GENERATION`.
Problem rules:
@@ -161,10 +187,10 @@ MUST output evidence count or example paths. Do not output only abstract judgmen
是否基于这个整理思路生成目标目录和移动 / 创建计划?
你可以选择:
A. 基于这个思路生成目标目录和计划
B. 调整整理思路
C. 查看问题详情
D. 取消本次整理
1. 基于这个思路生成目标目录和计划
2. 调整整理思路
3. 查看问题详情
4. 取消本次整理
```
## State: RULE_GENERATION
@@ -181,7 +207,8 @@ MUST:
6. For non-reused source containers, ensure `target_tree` includes a source-container cleanup target, defaulting to `待人工确认/待清理旧目录`, unless the user explicitly asks to keep source containers in place.
7. Ensure target tree can contain every planned `target_path`.
8. Ensure the target tree contains a manual confirmation target named `待人工确认` unless the user explicitly provides an equivalent name.
9. Continue to `PLAN_GENERATION` without a separate target-tree-only confirmation.
9. Apply `Analysis Progress Reporting`.
10. Continue to `PLAN_GENERATION` without a separate target-tree-only confirmation.
### Classification

View File

@@ -10,8 +10,9 @@ Before executing rules in this file:
1. Follow [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for identity, auth, and permission handling.
2. For Wiki / personal library targets, follow [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md).
3. For Drive search targets, follow [`lark-drive-search.md`](lark-drive-search.md).
4. For URL / token inspection, follow [`lark-drive-inspect.md`](lark-drive-inspect.md) and [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md).
3. For Drive folder inventory, follow [`lark-drive-files-list.md`](lark-drive-files-list.md).
4. For Drive search targets, follow [`lark-drive-search.md`](lark-drive-search.md).
5. For URL / token inspection, follow [`lark-drive-inspect.md`](lark-drive-inspect.md) and [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md).
## State: PARSE_SCOPE
@@ -87,6 +88,10 @@ Clarification template:
请确认是否按这个范围继续?
```
Scope confirmation is user-facing. It MUST confirm only the business scope, environment / profile, identity, and whether write operations will run.
Do not display internal batching controls in scope confirmation, including `max_depth`, `max_items`, `page_size`, page tokens, retry counts, or `partial=true`. For example, when the user confirms Drive root, say the scope is the Drive root tree; do not append "recursive depth at most 3" or "at most 500 resources".
## State: INVENTORY
Entry: `target_scope` confirmed.
@@ -96,20 +101,57 @@ MUST:
1. Recursively list resources according to target type.
2. Generate `path` during traversal.
3. Normalize all results to `ResourceItem`.
4. Track pagination, depth, and item limits.
5. Set `partial=true` when limits are hit.
6. Output `Inventory Summary`.
7. Continue to `CONTENT_READ` without asking the user unless auth, permission, API, target scope, or environment blockers occur.
4. Track pagination, depth, item limits, and continuation checkpoints.
5. Treat pagination, depth, item, and per-folder page limits as batching checkpoints; continue inventory in the confirmed scope unless blocked.
6. Set `partial=true` only when inventory cannot continue because of auth, permission, API / pagination failure after retries, API coverage limitations, tool budget, target scope, or environment blockers.
7. Apply `Inventory Progress Reporting`.
8. Output `Inventory Summary`.
9. Do not leave `INVENTORY` while `inventory_continuation_state` has queued folders, nodes, pages, or slices that can still be fetched.
10. Continue to `CONTENT_READ` without asking the user only after the confirmed scope is exhausted or blocked.
### Inventory Limits
### Inventory Batch Checkpoints
| Scope | Default Limit | If Limit Is Hit |
|-------|---------------|-----------------|
| Wiki recursion | `max_depth=3`, `max_items=500`; follow `lark-wiki-node-list` pagination | Set `partial=true`; list covered paths and suggested next first-level directories |
| Drive folder recursion | `max_depth=3`, `max_items=500`, max 10 pages per folder, `page_size=50` | Set `partial=true`; list folders not drilled into |
| Search discovery | `page_size=20`, `max_items=500`; continue pages until `has_more=false` or `max_items` is reached | Set `partial=true`; report collected_count, service_total when available, page_count, and continuation information |
| Scope | Internal Batch Checkpoint | Required Continuation |
|-------|---------------------------|-----------------------|
| Wiki recursion | `max_depth=3`, `max_items=500`; follow `lark-wiki-node-list` pagination | Record queued nodes / paths in `inventory_continuation_state` and immediately continue the next internal batch within the confirmed scope unless blocked |
| Drive folder tree | `max_depth=3`, `max_items=500`, max 10 pages per folder, `page_size=200` | Record queued folders / pages in `inventory_continuation_state` and immediately continue the next internal batch within the confirmed scope unless blocked |
| Search discovery | `page_size=20`, `max_items=500`; continue pages until `has_more=false` | Record remaining pages / slices in `inventory_continuation_state` and immediately continue the next internal batch within the confirmed scope unless blocked |
If the user explicitly asks for full processing, batch by first-level directory, Wiki space, or time window. Do not remove all limits in one run.
These checkpoints are pacing controls, not coverage limits. If the confirmed scope still has queued work after a checkpoint, continue with the next internal batch instead of presenting the current `resource_items` as final inventory or moving to content analysis.
When a depth checkpoint is reached, enqueue the child folders / nodes that would exceed the current batch depth; the next batch starts from those queued children with their original paths preserved. When an item checkpoint is reached, persist the current folder / node / page cursor plus the remaining queue, visited page keys, and resource dedupe keys, then continue from that checkpoint before analysis or planning.
If tool budget would be exceeded for a very large confirmed scope, stop only at that blocker, report that the inventory is incomplete, and suggest batching by first-level directory, Wiki space, or time window. Do not stop merely because a depth or item checkpoint was reached.
### Inventory Continuation Rules
1. Pagination, depth, item, and per-folder page limits are internal batching checkpoints.
2. When a checkpoint is reached, record `inventory_continuation_state` with `scope`, `queue`, `current_cursor`, `visited_page_keys`, `dedupe_keys`, and `blockers`; Drive queue entries MUST contain `folder_token`, `path`, `depth`, and `page_token`; Wiki queue entries MUST contain `space_id` / `node_token`, `path`, `depth`, and pagination cursor; search entries MUST contain query / filters and pagination cursor.
3. A depth checkpoint MUST enqueue deeper folders / nodes; it MUST NOT discard them or treat the current depth as final coverage.
4. An item-count checkpoint MUST persist the current cursor and queue; it MUST NOT transition to `CONTENT_READ`, `ISSUE_ANALYSIS`, or `PLAN_GENERATION` while fetchable work remains.
5. If `inventory_continuation_state` is missing, corrupt, or lacks required fields for the current scope, set `partial=true`, record the checkpoint blocker, and do not claim full coverage.
6. Do not set `partial=true` solely because a valid batching checkpoint was reached.
7. Set `partial=true` only when continuation is blocked by auth, permission, API / pagination failure after retries, API coverage limitations, tool budget, target scope, or environment blockers.
8. Do not claim full coverage until the continuation queue for the confirmed scope is exhausted or blocked.
### Inventory Progress Reporting
Inventory can be long-running when a Drive root, large folder tree, Wiki space, or broad search scope is confirmed.
Rules:
1. When inventory starts, output one concise stage notice with the confirmed scope type and the fact that no write operation will be executed.
2. If inventory runs longer than about 60 seconds, output progress about every 60 seconds.
3. Progress reports SHOULD include only fields that are currently known: scanned folders / nodes, collected resources, current depth, queued folders / nodes, current search page / slice, and current blocker if any.
4. When a batching checkpoint is reached and continuation will proceed automatically, report it as continuing inventory, not as a user action request.
5. Do not output filler such as "still running" without current counts or current stage.
6. Do not expose raw folder tokens, page tokens, retry logs, or `partial=true` unless the user explicitly asks to view inventory coverage details.
Example:
```text
盘点进度:已扫描 <scanned_container_count> 个目录 / 节点,收集 <resource_count> 项资源,队列剩余 <queued_container_count> 个目录 / 节点。继续盘点,不会执行移动或创建。
```
### Wiki Inventory Rules
@@ -120,11 +162,13 @@ If the user explicitly asks for full processing, batch by first-level directory,
### Drive Inventory Rules
1. Use CLI command family `drive files list` according to `lark-drive` API rules; its schema path is `drive.files.list`.
2. Recurse only into `folder` items.
3. Use `drive metas batch_query` when URL, owner, created time, or updated time is needed.
4. Continue pages by feeding `next_page_token` into request param `page_token`.
5. Prefer explicit `folder_token`; querying root with empty `folder_token` may return broad root data and may not paginate as expected.
1. Use `drive files list` according to [`lark-drive-files-list.md`](lark-drive-files-list.md); its schema path is `drive.files.list`.
2. Use the same Drive folder-tree traversal for Drive root and ordinary folders after the first request. Drive root differs only for the first-level request: it uses omitted or empty `folder_token`, does not support pagination, and does not return root-level shortcuts according to schema; returned child folders MUST still be listed by their own folder tokens like ordinary folders, and those ordinary folder lists may return `type=shortcut` entries. For a Drive root target, record this root-level shortcut coverage caveat, set `partial=true` only if the user requested full root-level shortcut coverage or root pagination cannot continue, and do not claim root-level shortcut coverage as complete.
3. Recurse only into `folder` items within the confirmed scope.
4. For each directory, continue pages manually by feeding the returned `next_page_token` into request param `page_token`. Do not rely on `--page-all` for inventory.
5. If a page returns `has_more=true` but no usable `next_page_token`, retry the same page request up to 3 times. If retries still cannot produce a continuation token, set `partial=true` for that directory and record the pagination blocker.
6. Use `drive metas batch_query` when URL, owner, created time, or updated time is needed.
7. Pagination blocker details such as `partial=true`, folder token, page token, and retry logs are internal by default. Do not show them to the user unless the user explicitly asks to view inventory coverage details.
### Search Inventory Rules
@@ -132,10 +176,11 @@ If the user explicitly asks for full processing, batch by first-level directory,
2. If a search result is a Wiki item and lacks `node_token`, resolve it with `drive +inspect` or `wiki +node-get` before dedupe.
3. If Wiki identity still cannot be resolved, keep the item, set `needs_review=true`, and record `needs_review_reason`.
4. For search scope, use `page_size=20` unless a lower value is required by the command.
5. Continue fetching pages until `has_more=false` or `max_items` is reached.
6. Do not stop at an arbitrary sample size such as first 5 pages unless the user explicitly asks for sampling or auth, permission, API, environment, or tool-budget blockers occur.
7. If `service_total` / result total is greater than collected items, set `partial=true` and show collected_count, service_total, page_count, and continuation information.
8. Do not present a partial search sample as complete inventory. Before generating a full organization plan from partial search results, ask whether to continue fetching more pages or proceed with sample-based planning.
5. Continue fetching pages until `has_more=false`.
6. If `max_items=500` is reached in one batch, record the current search cursor in `inventory_continuation_state` and continue the next internal batch without asking the user.
7. Do not stop at an arbitrary sample size such as first 5 pages unless the user explicitly asks for sampling or auth, permission, API, environment, or tool-budget blockers occur.
8. If `service_total` / result total is greater than collected items, treat it as continuation evidence: continue fetching when a cursor / page is available; set `partial=true` only if continuation is blocked.
9. Do not present a partial search sample as complete inventory. Before generating a full organization plan from partial search results, continue fetching available pages unless the user explicitly asked for sampling or a blocker prevents continuation.
## ResourceItem
@@ -179,7 +224,9 @@ ResourceItem rules:
## Inventory Summary
```text
已完成盘点。
已完成当前可覆盖范围盘点。
<仅当适用覆盖说明Drive 根目录第一层清单不返回快捷方式;本次盘点不包含根目录第一层快捷方式。根目录下子文件夹会按普通文件夹继续盘点,普通文件夹内返回的 `type=shortcut` 条目仍会被纳入资源清单。>
| 指标 | 数量 |
|------|------|
@@ -202,4 +249,5 @@ ResourceItem rules:
| Environment / profile is ambiguous | Ask user to confirm prod / BOE / PRE and profile | Do not cross environment boundaries |
| Missing API scope | Follow `lark-shared` permission handling and stop | Do not retry the same command repeatedly |
| Resource access denied | Stop and follow the main workflow `Permission Request Gate` | Do not request permission automatically or in batch |
| Pagination / depth / item limit reached | Set `partial=true`; record uncovered range and continuation command | Do not claim full coverage |
| Pagination / depth / item checkpoint reached | Record `inventory_continuation_state` and continue inventory in the confirmed scope | Do not set `partial=true` solely because a batching checkpoint was reached |
| Pagination cursor missing after retries / API pagination failure | Set `partial=true`; record the affected directory and blocker | Do not loop indefinitely or claim full coverage |

View File

@@ -24,7 +24,8 @@ MUST:
4. Apply `Plan Pagination`.
5. Set `active_plan_items` to the latest complete plan.
6. Keep complete plan internally even if only one page is displayed.
7. Output `Target Tree And Plan Overview` or requested plan page, then wait.
7. Apply `Plan Generation Progress Reporting`.
8. Output `Target Tree And Plan Overview` or requested plan page, then wait.
### Plan Generation
@@ -44,6 +45,25 @@ MUST:
| Target parent token unresolved | Keep plan item but block execution until token is resolved |
| Resource title is poor or inconsistent | Report the naming issue only; do not create rename or title-patch plan items |
### Plan Generation Progress Reporting
Plan generation can be long-running when `resource_items` is large or source-container parent / child move ordering is complex.
Rules:
1. If plan generation starts with more than 500 `resource_items`, output one concise start notice with the resource count and that no write operation is being executed.
2. If plan generation runs longer than about 60 seconds, output progress about every 60 seconds.
3. Progress reports SHOULD include only fields currently known: processed resource count, generated plan item count, create count, move count, source-container move count, review count, and current step.
4. Do not display unpaginated plan details as progress. Complete `plan_items` remain internal until the normal paginated output.
5. Do not ask the user to continue during plan generation unless auth, permission, API, target scope, or environment blockers occur.
6. Do not output filler such as "still running" without current counts or current step.
Example:
```text
计划生成进度:已处理 <processed_count>/<resource_count> 项资源,生成 <plan_item_count> 项计划,其中创建 <create_count> 项、移动 <move_count> 项。继续计算父子目录移动顺序,不会执行创建或移动。
```
## PlanItem
`PlanItem` is for internal execution. It may contain tokens and internal enums.
@@ -167,11 +187,11 @@ Confidence display map:
- 低置信度:<low_count> 项
你可以选择:
- 查看第 1 页明细
- 只看将创建的目录 / 节点
- 只看待人工确认项
- 只看高置信度移动项
- 进入执行确认
1. 查看第 1 页明细
2. 只看将创建的目录 / 节点
3. 只看待人工确认项
4. 只看高置信度移动项
5. 进入下一步:确认执行计划
```
If `total_count > 500`, say:
@@ -224,10 +244,10 @@ User-facing output:
说明:后续执行默认基于这份完整修正版计划,不是只执行刚才的修正项。
你可以选择:
A. 查看修正版计划总览
B. 查看本次修改涉及的资源
C. 进入执行确认
D. 继续调整
1. 查看修正版计划总览
2. 查看本次修改涉及的资源
3. 进入下一步:确认执行计划
4. 继续调整
```
If the user explicitly asks to execute only the corrected items, ask for confirmation before execution:
@@ -248,15 +268,15 @@ If the user explicitly asks to execute only the corrected items, ask for confirm
还有 <remaining_pages> 页未展示。
你可以回复:
- 继续看下一页
- 只看待人工确认项
- 只看低置信度项
- 进入执行确认
1. 继续看下一页
2. 只看待人工确认项
3. 只看低置信度项
4. 进入下一步:确认执行计划
```
## State: EXEC_CONFIRM
Entry: user asks to execute.
Entry: user asks to view execution confirmation or continue toward execution.
MUST:
@@ -284,17 +304,17 @@ Before execution confirmation, MUST show this notice:
When the user wants execution, ask for execution scope:
Execution confirmation options MUST be renumbered by currently available choices. Do not show disabled choices, and do not ask the user to reply with skipped letters.
Execution confirmation options MUST be numbered by currently available choices. Do not show disabled choices, and do not ask the user to reply with skipped numbers.
If a plan detail page is currently active:
```text
请确认执行范围:
A. 执行完整计划:<total_count> 项
B. 只执行当前页:<current_page_count> 项
C. 只执行高置信度项:<high_confidence_count> 项
D. 暂不执行,只保留方案
1. 执行完整计划:<total_count> 项
2. 只执行当前页:<current_page_count> 项
3. 只执行高置信度项:<high_confidence_count> 项
4. 暂不执行,只保留方案
本 workflow 只执行已确认范围内的创建、移动和必要的单资源权限申请;不会重命名任何资源。
```
@@ -304,9 +324,9 @@ If no plan detail page is currently active:
```text
请确认执行范围:
A. 执行完整计划:<total_count> 项
B. 只执行高置信度项:<high_confidence_count> 项
C. 暂不执行,只保留方案
1. 执行完整计划:<total_count> 项
2. 只执行高置信度项:<high_confidence_count> 项
3. 暂不执行,只保留方案
如需只执行某一页,请先查看计划明细页。

View File

@@ -89,7 +89,8 @@ Agent MUST maintain these internal fields during one workflow run:
| `environment_profile` | Current environment and CLI profile, such as prod / BOE / PRE and config profile |
| `identity` | `user` by default unless user explicitly asks for app / bot perspective |
| `resource_items` | Complete normalized resource list from discovery |
| `partial` | Whether inventory or content-read limits were hit |
| `partial` | Whether inventory or content read cannot fully continue because of auth, permission, API / pagination failure after retries, API coverage limitations, tool budget, or scope blockers; batching checkpoints alone are not partial |
| `inventory_continuation_state` | Structured checkpoint for continuing inventory batches within the confirmed scope. Must preserve `scope`, `queue`, `current_cursor`, `visited_page_keys`, `dedupe_keys`, and `blockers`; Drive queue entries carry `folder_token`, `path`, `depth`, and `page_token`; Wiki queue entries carry `space_id` / `node_token`, `path`, `depth`, and pagination cursor; search entries carry query / filters and pagination cursor. Missing or corrupt state is a blocker, not a completed inventory. |
| `low_confidence_items` | Items requiring mandatory partial content read |
| `issue_summary` | Problem types, counts, evidence paths, and suggested handling |
| `classification_rules` | Rules used to map resources to target paths |
@@ -211,6 +212,7 @@ Never request permission automatically, never batch permission requests, and nev
- [Rollback phase](lark-drive-workflow-knowledge-organize-rollback.md)
- [lark-shared](../../lark-shared/SKILL.md)
- [lark-drive](../SKILL.md)
- [lark-drive-files-list](lark-drive-files-list.md)
- [lark-drive-search](lark-drive-search.md)
- [lark-drive-inspect](lark-drive-inspect.md)
- [lark-drive-apply-permission](lark-drive-apply-permission.md)

View File

@@ -87,31 +87,40 @@ lark-cli mail +triage --page-size 10
"messages": [
{
"message_id": "SEU2...",
"mailbox_id": "me",
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
"from": "Alice <alice@example.com>",
"subject": "Weekly update",
"labels": "INBOX,UNREAD"
}
],
"mailbox_id": "me",
"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 路径,不可混用
### `table` 格式
`page_token` 信息输出在 stderr自动携带 `--query`/`--filter` 参数方便续页:
`page_token` 信息输出在 stderr自动携带 `--query`/`--filter`/`--mailbox` 参数方便续页:
```text
15 message(s)
next page: mail +triage --query '合同审批' --page-token 'search:abc123...'
tip: use mail +message --message-id <id> to read full content
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
```text
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
tip: use mail +message --mailbox 'shared@example.com' --message-id <id> to read full content
```
### 搜索分页注意事项
搜索路径(使用 `--query``from`/`to`/`subject` 等 filter的分页结果在**同一翻页链内**保持一致(无重复、无丢失)。但不同 `--max` 值发起的独立搜索可能返回不同排序,这是搜索 API 的固有行为。列表路径(仅 `folder`/`label` 筛选)无此限制。

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、获取幻灯片截图、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]
@@ -18,7 +18,11 @@ metadata:
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 先确认 `slide_id` 列表,再用 shortcut 保存本地图片;不要把 Base64 输出给模型 | `slides +screenshot``lark-slides-screenshot.md` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
@@ -79,7 +83,10 @@ lark-cli auth login --domain slides
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
@@ -183,7 +190,7 @@ lark-cli slides xml_presentation.slide create \
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
<data>
在这里放置 shape、line、table、chart 等元素
在这里放置 shape、line、table、chart、whiteboard 等元素
</data>
</slide>' '{slide:{content:$content}}')"

View File

@@ -1,13 +1,13 @@
# Asset Planning
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, or placeholder regions.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
@@ -22,7 +22,7 @@ Use an object for one planned asset, or an array when a page genuinely needs mul
"asset_type": "architecture_diagram",
"purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
"suggested_query": "agent native slides runtime architecture diagram",
"fallback_if_missing": "Draw grouped boxes and arrows with XML shapes; use labels instead of an image."
"fallback_if_missing": "Draw grouped boxes connected by arrows with short labels."
}
```
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
- `icon`: small semantic symbol for a concept, step, role, or status.
- `logo`: brand, product, team, or customer mark.
- `chart`: line, bar, pie, funnel, scatter, or chart-like data visual.
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
- `screenshot`: product UI, terminal output, workflow state, or page capture.
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
@@ -118,7 +118,7 @@ Business comparison page:
When generating XML:
1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, or chart-like elements.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
# IconPark 图标
IconPark 图标通过 `<icon>` 写入 slides XML`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
## 机器优先流程
```bash
python3 skills/lark-slides/scripts/iconpark_tool.py search --query "增长趋势" --limit 8
python3 skills/lark-slides/scripts/iconpark_tool.py resolve --name chart-line
python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
```
`search` 返回 JSON 数组,每项包含 `iconType``category``name``tags``score`。直接把选中的 `iconType` 写入 XML并为图标指定可见颜色
```xml
<icon iconType="iconpark/Charts/chart-line.svg" topLeftX="80" topLeftY="120" width="32" height="32">
<fill>
<fillColor color="rgba(37, 99, 235, 1)"/>
</fill>
</icon>
```
## 使用规则
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8``--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
- 常用尺寸:行内状态图标 16-24px卡片标题图标 28-40px主视觉图标 56-96px。
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback不留空图标位。
## 高频示例
| 语义 | iconType |
|---|---|
| 设置/配置 | `iconpark/Base/setting.svg` |
| 目标 | `iconpark/Base/aiming.svg` |
| 增长趋势 | `iconpark/Charts/positive-dynamics.svg` |
| 折线趋势 | `iconpark/Charts/chart-line.svg` |
| 占比 | `iconpark/Charts/chart-proportion.svg` |
| 数据看板 | `iconpark/Charts/data-screen.svg` |
| 成功 | `iconpark/Character/check-one.svg` |
| 失败/风险 | `iconpark/Character/close-one.svg` |
| 团队/用户 | `iconpark/Peoples/peoples.svg` |
| 安全防护 | `iconpark/Safe/protect.svg` |
| 全球/市场 | `iconpark/Travel/world.svg` |
| 邮件/联系 | `iconpark/Office/envelope-one.svg` |

View File

@@ -84,14 +84,15 @@ lark-cli slides +replace-slide --as user \
| `<line>` | 直线 | 需 `startX/startY/endX/endY` |
| `<polyline>` | 折线 | `points` 读回时被服务端规整丢弃(几何已入库) |
| `<img>` | 图片 | `src` 必须是 [`+media-upload`](lark-slides-media-upload.md) 返回的 `file_token`,不能是 URL |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源 |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源;语义图标先用 `scripts/iconpark_tool.py search` 检索 |
| `<table>` | 表格 | 整表替换会**重建内部 td id**,旧 td block_id 立即失效 |
| `<td>` | 单元格局部替换 | 只能 `block_replace`,不能 `block_insert``block_id` 必须是最新 `slide.get` 拿到的 td id |
| `<chart>` | 图表line/bar/column/pie/area/radar/combo | 必须嵌 `<chartPlotArea>` + `<chartData>` + `<dim1>/<dim2>/<chartField>` |
| `<whiteboard>` | 画板SVG 或 Mermaid | 内嵌 `<svg>``<mermaid>``slide.get` 返回结构不含内部数据,但可直接写完整新 XML 做 `block_replace` 覆盖;详见 [`lark-slides-whiteboard.md`](lark-slides-whiteboard.md) |
**不可作为根元素**
- `<video>` / `<audio>` / `<whiteboard>` —— SML 2.0 没有这个原生元素;`<undefined type="video|audio|whiteboard">` 是**导出时**的占位符(服务端遇到不支持的类型时用它代替),**不能写入**。尝试 insert/replace 都会返回 3350001。
- `<video>` / `<audio>` —— SML 2.0 没有这个原生元素;`<undefined type="video|audio">` 是**导出时**的占位符(服务端遇到不支持的类型时用它代替),**不能写入**。尝试 insert/replace 都会返回 3350001。
### 最小 XML 片段JSON 嵌入时记得把 `"` 转义成 `\"`

View File

@@ -0,0 +1,104 @@
# slides +screenshot
## 用途
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图,底层调用 `xml_presentation.slide_image.list`;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览,底层调用 `xml_presentation.slide_image.render`。两个 API 都返回 Base64 图片内容;本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
## 命令
```bash
lark-cli slides +screenshot --as user \
--presentation '<xml_presentation_id 或 slides/wiki URL>' \
--slide-id '<slide_id>'
```
渲染本地 XML 内容:
```bash
lark-cli slides +screenshot --as user \
--content @slide.xml
```
## 参数
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | list 模式必需 | `xml_presentation_id``/slides/` URL或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file``-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
| `--output-name` | 否 | render 模式的输出文件名 stem未指定时优先用返回的 `slide_id`,否则用 `rendered-slide` |
## 示例
### 单页截图
```bash
lark-cli slides +screenshot --as user \
--presentation slides_example_presentation_id \
--slide-id slide_example_id
```
### 多页截图
```bash
lark-cli slides +screenshot --as user \
--presentation slides_example_presentation_id \
--slide-id slide_1 \
--slide-id slide_2 \
--output-dir .lark-slides/screenshots/demo
```
### 按页号截图
```bash
lark-cli slides +screenshot --as user \
--presentation slides_example_presentation_id \
--slide-number 1 \
--slide-number 2
```
### 渲染 XML 预览
```bash
lark-cli slides +screenshot --as user \
--content @.lark-slides/out/demo/slide.xml \
--output-name preview
```
## 返回值
返回 JSON 不包含 Base64 图片内容:
```json
{
"code": 0,
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"output_dir": ".lark-slides/screenshots",
"screenshots": [
{
"slide_id": "slide_example_id",
"slide_number": 1,
"format": "png",
"path": "/abs/path/.lark-slides/screenshots/slide_example_id.png",
"size": 12345
}
]
},
"msg": "success"
}
```
## 获取 slide_id
不知道页面 ID传 slide id 即可。
## 注意事项
1. 优先使用 `slides +screenshot`,不要直接调用 `xml_presentation.slide_image.list` / `xml_presentation.slide_image.render` 后把 Base64 打到 stdout。
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id``--slide-number`
3. 本地 XML 预览时,传 `--content @file``--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`
4. `slide_id` 是页面 short ID页码请用 `--slide-number`
5. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。

View File

@@ -0,0 +1,330 @@
# Whiteboard 画板元素
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG****Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>``<shape>` 难以覆盖的视觉内容。
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
---
## `<chart>` 还是 `<whiteboard>`
**先判断内容类型,再进入本文档:**
| 场景 | 推荐元素 |
|------|---------|
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高。
---
## whiteboard 公共属性
| 属性 | 必需 | 说明 |
|------|------|------|
| `topLeftX` | 是 | 左上角 X 坐标slide 坐标系slide 默认宽 960 |
| `topLeftY` | 是 | 左上角 Y 坐标slide 坐标系slide 默认高 540 |
| `width` | 是 | 画板宽度(像素) |
| `height` | 是 | 画板高度(像素) |
> SVG 模式下 `<svg>` 需声明 `xmlns="http://www.w3.org/2000/svg"`;内容大小由子元素包围盒决定,`width`/`height`/`viewBox` 不影响渲染(仅当元素属性使用百分比值时需要 `viewBox` 提供计算基准。Mermaid 模式不需要额外属性。
SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标系无关。
---
## SVG 还是 Mermaid
选择分两步:**先看图表类型,再看当前模型身份**。
### 第一步:图表类型优先判断
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG
| 图表类型 | Mermaid 关键字 |
|----------|--------------|
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
| 时序图 | `sequenceDiagram` |
| 类图 | `classDiagram` |
| 饼图 | `pie` |
| 甘特图 | `gantt` |
| 状态图 | `stateDiagram-v2` |
| 思维导图 | `mindmap` |
| ER 图 | `erDiagram` |
### 第二步:数据图表与装饰元素按模型身份选路径
上表以外的场景散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等需要精确控制坐标和配色SVG 表达力更强,但各模型生成 SVG 的能力有差异:
| 模型身份 | 路径 |
|----------|------|
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
| Doubao / Seed / Other | **Mermaid** — 用 `pie``gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
---
## 模式一SVG
### ⚠️ 设计品质要求
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去。
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或点
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
- **装饰层次用亮度跳跃,不用线性叠透明度**`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
### 语法
```xml
<whiteboard width="400" height="300" topLeftX="500" topLeftY="120">
<svg xmlns="http://www.w3.org/2000/svg">
<rect x="50" y="50" width="80" height="200" rx="4" fill="rgba(59,130,246,0.85)"/>
<text x="90" y="270" text-anchor="middle" font-size="12" fill="rgba(100,116,139,1)">ABC</text>
</svg>
</whiteboard>
```
`<svg>` 需声明 `xmlns="http://www.w3.org/2000/svg"``width`/`height`/`viewBox` 无需填写,若元素属性使用百分比值则需额外声明 `viewBox`
### ⚠️ 渲染包围盒规则
whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内容区域,自适应缩放到容器。
`<svg>` 上的 `width``height``viewBox` 不影响内容区域的计算,但 `viewBox` 有一个实际用途:**为百分比属性提供计算基准**。若元素使用 `width="50%"` 等百分比值,必须声明 `viewBox` 才能正确解析;绝对坐标元素则无需关心。推荐统一使用绝对坐标,避免引入百分比依赖。
### 支持的 SVG 元素
| 元素 | 说明 | 典型用途 |
|------|------|---------|
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
| `<circle>` | 圆 | 节点、装饰点、环形图 |
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
| `<line>` | 直线 | 坐标轴、分隔线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、折线、弧形 |
| `<text>` | 文本,支持中文 | 标签、数值 |
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
| `<g>` | 分组 | 批量变换、语义分组 |
| `<linearGradient>` | 线性渐变定义,配合 `fill="url(#id)"` 使用 | 渐变背景、渐变填充 |
**颜色:** 统一用 `rgba(R,G,B,A)`,对深浅背景都友好。
**虚线:** `stroke-dasharray="4,4"` 用于网格线 / 坐标轴。
**变换:** `transform="translate(x,y)"` / `rotate(deg cx cy)` / `scale(n)` 均支持。
---
### 元素计算
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用。
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
**数据图表(柱状图范式)**
```python
W, H = 360, 260
origin_x, origin_y = 50, 216 # 左下角SVG Y 轴向下
cw, ch = 290, 184
data, y_max = [120, 160, 90], 200
bar_w = int(cw / len(data) * 0.62)
for i, v in enumerate(data):
cx = round(origin_x + (i + 0.5) * cw / len(data))
y = round(origin_y - v / y_max * ch)
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
```
折线图:`x = origin_x + i/(n-1)*cw``y = origin_y - (v-y_min)/(y_max-y_min)*ch`
**装饰性元素(等间距范式)**
```python
n, total_w, cy, r = 8, 340, 40, 4
step = total_w / (n - 1)
for i in range(n):
print(f"circle-{i}: cx={round(i * step)} cy={cy} r={r}")
```
**最大包围盒 → whiteboard 尺寸**
所有元素坐标算完后,汇总出整体包围盒,直接作为 whiteboard 的 `width`/`height`
```python
# 每个元素登记 (x, y, w, h),含 stroke 外扩
elements = [
(10, 20, 80, 160), # bar-0
(107, 10, 80, 170), # bar-1
(204, 40, 80, 140), # bar-2
(0, 0, 300, 1), # x-axis
]
xs = [x for x, y, w, h in elements]
ys = [y for x, y, w, h in elements]
x2 = [x + w for x, y, w, h in elements]
y2 = [y + h for x, y, w, h in elements]
wb_w = max(x2) - min(xs)
wb_h = max(y2) - min(ys)
print(f"whiteboard width={wb_w} height={wb_h}")
```
输出即 `<whiteboard width=... height=...>` 的值,无需手动估算。
---
### 布局模式
**全屏装饰层**
```xml
<whiteboard width="960" height="540" topLeftX="0" topLeftY="0">
<svg xmlns="http://www.w3.org/2000/svg">
...
</svg>
</whiteboard>
```
> ⚠️ 全屏装饰 whiteboard 必须放在所有 `<shape>` / `<img>` / `<table>` 之前否则会遮挡文字内容。XML 中元素位置越靠后,渲染层级越高。
**侧栏图表(与文字 shape 并排)**
```xml
<!-- 左侧文字 -->
<shape type="text" topLeftX="60" topLeftY="120" width="500" height="340">...</shape>
<!-- 右侧图表 -->
<whiteboard width="340" height="340" topLeftX="580" topLeftY="120">
<svg xmlns="http://www.w3.org/2000/svg">
...
</svg>
</whiteboard>
```
**底部装饰条**
```xml
<whiteboard width="960" height="100" topLeftX="0" topLeftY="440">
<svg xmlns="http://www.w3.org/2000/svg">
...
</svg>
</whiteboard>
```
---
### 禁止使用的 SVG 特性
以下特性在 slide `<whiteboard>` 渲染端不支持或行为不可预测,必须避免:
| 禁止 | 原因 | 替代方案 |
|------|------|---------|
| `<radialGradient>` | 渲染失败 | 用 `<linearGradient>``rgba()` 透明度模拟深浅层次 |
| `<filter>`(阴影、模糊等) | 渲染失败 | 用半透明 `<rect>` 叠加模拟阴影 |
| `<clipPath>` / `<mask>` | 渲染失败 | 调整元素坐标和尺寸自然裁切 |
| `<pattern>` | 渲染失败 | 手动铺 `<circle>` / `<rect>` 点阵 |
| `skewX` / `skewY` / `matrix(...)` | 空间扭曲,降级渲染 | 用 `rotate` + `translate` 替代 |
| `<image>` 外链 URL | 不支持外链 | 先上传得到 file_token再用 `<img>` 元素 |
---
## 模式二Mermaid
### 语法
```xml
<whiteboard topLeftX="72" topLeftY="60" width="816" height="360">
<mermaid>
<![CDATA[
flowchart TD
A[检查 lark-cli 与 jq] --> B[编写每页 slide XML]
B --> C[通过 jq 生成 slides JSON]
C --> D[执行 slides +create]
D --> E[读取 xml_presentation_id]
E --> F[回读并验证创建结果]
]]>
</mermaid>
</whiteboard>
```
**关键点:**
- 内容用 `<![CDATA[...]]>` 包裹——Mermaid 语法里的 `[``>``-->` 是 XML 特殊字符CDATA 避免转义问题
- whiteboard 只需 `topLeftX``topLeftY``width``height`
### 支持的 Mermaid 图表类型
| 类型 | 关键字 | 适用场景 |
|------|--------|---------|
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
| 甘特图 | `gantt` | 项目计划、里程碑 |
| 饼图 | `pie` | 占比数据 |
| 类图 | `classDiagram` | 对象关系、架构设计 |
| ER 图 | `erDiagram` | 数据库结构 |
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
| 思维导图 | `mindmap` | 主题梳理、知识架构 |
| 用户旅程 | `journey` | 用户体验路径 |
### Mermaid 布局建议
Mermaid 图表会自动撑满 whiteboard 区域。建议:
- 流程图留足高度,节点较多时适当增加 height比如 400-480
- 避免一页放超过 15 个节点,内容太密时考虑分页
- 推荐尺寸参考:
| 图表类型 | 建议 width | 建议 height |
|---------|-----------|------------|
| 流程图5-8 节点) | 720-816 | 300-400 |
| 时序图3-5 参与者) | 720-816 | 320-420 |
| 饼图 | 500-600 | 300-360 |
| 甘特图 | 816 | 280-360 |
| 思维导图 | 816 | 380-480 |
---
## 注意事项 & 已知问题
### z-orderSVG 模式)
whiteboard 在 XML 中的位置决定渲染层级:在 shape 前 → 在下层;在 shape 后 → 在上层。全屏装饰 whiteboard 应放在所有 shape 之前。
### Mermaid CDATA 必要性
Mermaid 语法包含 `[``>``-->`,不用 CDATA 直接写会破坏 XML 解析。始终使用 `<![CDATA[ ... ]]>`
---
## 快速自检清单
**SVG 模式——结构检查:**
- [ ] `<svg>` 声明了 `xmlns="http://www.w3.org/2000/svg"`
- [ ] whiteboard 的 `width`/`height` 由所有元素的最大包围盒(含 stroke 外扩)计算得出,不手动估值
- [ ] `topLeftX + width ≤ 960``topLeftY + height ≤ 540`
- [ ]`<radialGradient>` / `<filter>` / `<clipPath>`
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size避免被裁切
**SVG 模式——视觉品质检查:**
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
- [ ] 轴标签与图表元素互不遮挡,留有足够空间
- [ ] 坐标推导有注释(写明 originX/Y、chartW/H、数据映射公式
**Mermaid 模式:**
- [ ] 内容包在 `<![CDATA[...]]>`
- [ ] CDATA 结束符 `]]>` 不出现在 Mermaid 代码本身中
- [ ] `topLeftX + width ≤ 960``topLeftY + height ≤ 540`
- [ ] 节点数量合理(单图不超过 15-20 个节点)
**通用:**
- [ ] XML 标签全部闭合,属性引号完整
- [ ] 如果失败,检查是否是偶发 5001000重试一次
---
## 参考
- [lark-slides SKILL.md](../SKILL.md)

View File

@@ -162,7 +162,7 @@ lark-cli slides xml_presentation.slide create --as user \
| 元素 | 说明 |
|------|------|
| `<style>` | 页面样式(背景填充) |
| `<data>` | 图形元素容器shape、img、table、chart 等) |
| `<data>` | 图形元素容器shape、img、table、chart、whiteboard 等) |
| `<note>` | 演讲者备注 |
> [!IMPORTANT]

View File

@@ -94,7 +94,7 @@ lark-cli slides xml_presentation.slide get --as user --params '{
## 注意事项
1. **执行前必做**`lark-cli schema slides.xml_presentation.slide.get` 查看最新参数结构
2. **block_id 提取**:返回 XML 里每个顶层块shape、img、table 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id
2. **block_id 提取**:返回 XML 里每个顶层块shape、img、table、chart、whiteboard 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id
```bash
lark-cli slides xml_presentation.slide get --as user \

View File

@@ -171,12 +171,13 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
## 注意事项
1. **parts 原子事务**:任一条失败整批回滚,不会出现"前几条成功、后几条失败"的中间态。
2. **block_id 的获取**`slide.get` 返回的 XML 里每个块shape、img、table、chart 等)会带 3 位 short element ID用这个值填 `block_id` / `insert_before_block_id`
2. **block_id 的获取**`slide.get` 返回的 XML 里每个块shape、img、table、chart、whiteboard 等)会带 3 位 short element ID用这个值填 `block_id` / `insert_before_block_id`
3. **`<img>` 必须用 file_token**:不能用外链 URL——先 [`slides +media-upload`](lark-slides-media-upload.md) 拿 token。
4. **不能字段级 patch**:要改一个块的某个属性(比如只改 `topLeftX`),得写整块新 XML 走 `block_replace`API 不支持"只改一个字段"。
5. **`block_replace` 要求 `replacement` 根元素带 `id="<block_id>"`**:底层 API 的硬约束,缺失会返回 3350001。推荐走 shortcut [`+replace-slide`](lark-slides-replace-slide.md)——它会自动把 `id` 注入到 `replacement` 根元素上,用户写 XML 时不用自己加。
6. **`<shape>` 必须有 `<content/>` 子元素**SML 2.0 schema 要求,缺失同样触发 3350001。shortcut [`+replace-slide`](lark-slides-replace-slide.md) 会自动注入 `<content/>`,直接调底层 API 需要自己加。
7. **执行前必做**`lark-cli schema slides.xml_presentation.slide.replace` 查看最新参数结构
7. **`<whiteboard>` 返回结构不含内部数据**`slide.get` 返回的 whiteboard 块只有外层标签和位置属性SVG / Mermaid 内容不会随 XML 一起返回。但 `block_replace` 仍然可以强行覆盖——直接写入完整新 whiteboard XML 即可
8. **执行前必做**`lark-cli schema slides.xml_presentation.slide.replace` 查看最新参数结构。
## 相关命令

View File

@@ -185,15 +185,15 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
- `purpose`: why this asset helps the page's key message.
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
- `fallback_if_missing`: concrete XML-native visual plan using shapes, arrows, labels, tables, simple charts, or placeholder panels.
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
For detailed rules and examples, read `asset-planning.md`.
Good examples:
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw grouped boxes and arrows with short labels."}`
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple line chart with shapes and value labels."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
## XML Generation Contract

View File

@@ -935,7 +935,7 @@
单页幻灯片结构
子元素:
- style: 页面样式(背景色等), style的fill默认颜色为白色rgba(255, 255, 255, 1)
- data: 页面元素容器(shape/line/polyline/img/table/icon/chart/undefined)
- data: 页面元素容器(shape/line/polyline/img/table/icon/chart/whiteboard/undefined)
- note: 演讲者备注
</xs:documentation>
</xs:annotation>
@@ -960,6 +960,7 @@
<xs:element ref="sml:table"/>
<xs:element ref="sml:icon"/>
<xs:element ref="sml:chart"/>
<xs:element ref="sml:whiteboard"/>
<xs:element ref="sml:undefined"/>
</xs:choice>
</xs:complexType>
@@ -1206,7 +1207,7 @@
未定义元素, 用于处理不支持的形状类型, 当导出时遇到不支持的type数据时, 使用此元素替代
属性说明:
- id: 元素唯一标识符(可选)
- type: 原始的不支持的类型名称, 包括 video(视频), audio(音频), whiteboard(画板)
- type: 原始的不支持的类型名称, 包括 video(视频), audio(音频)
</xs:documentation>
</xs:annotation>
<xs:complexType>
@@ -3001,4 +3002,48 @@
<xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
</xs:complexType>
</xs:element>
<!-- 画板元素 -->
<xs:element name="whiteboard">
<xs:annotation>
<xs:documentation>
画板元素, 用于在幻灯片中嵌入 Mermaid 或 SVG 绘制内容。
属性说明:
- id: 画板唯一标识符(可选)
- topLeftX/topLeftY: 左上角坐标, 必须
- width/height: 宽高尺寸, 必须
- flipX/flipY: 水平/垂直翻转
- alpha: 不透明度[0,1]
子元素(mermaid 与 svg 二选一):
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
示例: &lt;mermaid&gt;&lt;![CDATA[flowchart TD\n A[开始] --&gt; B[结束]]]&gt;&lt;/mermaid&gt;
- svg: SVG 内容
适用场景: 需要精确控制坐标、配色、路径的自定义图形
特点: 像素级精确定位,支持 rect/circle/path/text/polygon/g/linearGradient 等元素radialGradient/filter/clipPath/mask/pattern 不支持,需手动计算所有坐标
示例: &lt;svg xmlns="http://www.w3.org/2000/svg"&gt;...&lt;/svg&gt;xmlns 必填width/height/viewBox 不影响渲染,仅百分比属性值场景需声明 viewBox
- border: 边框样式, 可选, 无border标签代表无边框, 空border标签代表使用默认样式
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:choice>
<xs:element name="mermaid" type="xs:string" />
<xs:any namespace="http://www.w3.org/2000/svg" processContents="skip"/>
</xs:choice>
<xs:element name="border" type="sml:BorderType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="optional"/>
<xs:attribute name="topLeftX" type="sml:XType" use="required"/>
<xs:attribute name="topLeftY" type="sml:YType" use="required"/>
<xs:attribute name="width" type="sml:PositiveSize" use="required"/>
<xs:attribute name="height" type="sml:PositiveSize" use="required"/>
<xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
</xs:complexType>
</xs:element>
</xs:schema>

Some files were not shown because too many files have changed in this diff Show More