mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Compare commits
14 Commits
chore/spli
...
v1.0.31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acf64c3ef | ||
|
|
52e0129078 | ||
|
|
8a8dff47ce | ||
|
|
1c2d3d7679 | ||
|
|
0d20f88453 | ||
|
|
b0bd9b0258 | ||
|
|
ba6edb84e4 | ||
|
|
a54a879330 | ||
|
|
a27c636131 | ||
|
|
37459b60ec | ||
|
|
f1aa7d8f42 | ||
|
|
a18504b1f9 | ||
|
|
5e0ac02f08 | ||
|
|
b0c9a4d74e |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -2,6 +2,33 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.31] - 2026-05-14
|
||||
|
||||
### Features
|
||||
|
||||
- **install**: Skip interactive prompts in non-TTY environments (#888)
|
||||
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
|
||||
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
|
||||
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
|
||||
- **drive**: Add modified-time smart sync mode (#859)
|
||||
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
|
||||
|
||||
## [v1.0.30] - 2026-05-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
|
||||
- **auth**: Clarify URL handling in auth messages and docs (#856)
|
||||
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
|
||||
|
||||
### Tests
|
||||
|
||||
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
@@ -676,6 +703,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
|
||||
21
README.md
21
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 25 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 25 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 25 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -62,11 +62,7 @@ Choose **one** of the following methods:
|
||||
**Option 1 — From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**Option 2 — From source:**
|
||||
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
|
||||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**Step 2 — Configure app credentials**
|
||||
@@ -142,8 +134,7 @@ lark-cli auth status
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides-creator` | Create polished presentations with planning, design, asset, template, and validation workflows |
|
||||
| `lark-slides` | Low-level Slides XML/API read/write operations |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
|
||||
12
README.zh.md
12
README.zh.md
@@ -62,11 +62,7 @@
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**方式二 — 从源码安装:**
|
||||
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**第 2 步 — 配置应用凭证**
|
||||
|
||||
@@ -30,6 +30,7 @@ type LoginOptions struct {
|
||||
Scope string
|
||||
Recommend bool
|
||||
Domains []string
|
||||
Exclude []string
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
}
|
||||
@@ -62,11 +63,13 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
@@ -158,6 +161,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
@@ -185,14 +192,17 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
finalScope := opts.Scope
|
||||
// Normalize --scope so users can pass either OAuth-standard space-separated
|
||||
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
|
||||
// space-delimited scopes in the wire request, so the device authorization
|
||||
// endpoint rejects raw "a,b" strings as a single malformed scope.
|
||||
finalScope := normalizeScopeInput(opts.Scope)
|
||||
|
||||
// Resolve scopes from domain/permission filters
|
||||
// Resolve scopes from domain/permission filters and merge with --scope.
|
||||
// --scope, --domain, and --recommend combine additively so callers can,
|
||||
// for example, request all `docs` scopes plus a few specific `drive`
|
||||
// scopes in a single command.
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
if opts.Scope != "" {
|
||||
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
|
||||
}
|
||||
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
@@ -206,11 +216,35 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
|
||||
}
|
||||
|
||||
if len(candidateScopes) == 0 {
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||
}
|
||||
|
||||
finalScope = strings.Join(candidateScopes, " ")
|
||||
// Merge --scope additively with the resolved domain scopes.
|
||||
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
|
||||
for _, s := range candidateScopes {
|
||||
merged[s] = true
|
||||
}
|
||||
for _, s := range strings.Fields(finalScope) {
|
||||
merged[s] = true
|
||||
}
|
||||
finalScope = joinSortedScopeSet(merged)
|
||||
}
|
||||
|
||||
// Apply --exclude on top of the resolved scope set. We honour exclude
|
||||
// regardless of whether scopes came from --scope, --domain, --recommend,
|
||||
// or any combination thereof.
|
||||
if len(opts.Exclude) > 0 {
|
||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||
if len(unknown) > 0 {
|
||||
return output.ErrValidation(
|
||||
"these --exclude scopes are not present in the requested set: %s",
|
||||
strings.Join(unknown, ", "))
|
||||
}
|
||||
finalScope = excluded
|
||||
if strings.TrimSpace(finalScope) == "" {
|
||||
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Request device authorization
|
||||
@@ -473,7 +507,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
}
|
||||
@@ -532,6 +566,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeScopeInput accepts a user-supplied --scope value that may use
|
||||
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
|
||||
// canonical OAuth 2.0 wire form: a single space-joined string with empties
|
||||
// trimmed and duplicates removed (first occurrence wins; order preserved).
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
|
||||
// "a, b , c" -> "a b c"
|
||||
// "a b a" -> "a b"
|
||||
// "" -> ""
|
||||
func normalizeScopeInput(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Treat both commas and any whitespace as separators.
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, ok := seen[f]; ok {
|
||||
continue
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
out = append(out, f)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// suggestDomain finds the best "did you mean" match for an unknown domain.
|
||||
func suggestDomain(input string, known map[string]bool) string {
|
||||
// Check common cases: prefix match or input is a substring
|
||||
@@ -542,3 +610,58 @@ func suggestDomain(input string, known map[string]bool) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinSortedScopeSet returns a deterministic, space-separated scope string
|
||||
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
|
||||
func joinSortedScopeSet(set map[string]bool) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// applyExcludeScopes removes the provided exclude entries from the requested
|
||||
// scope string. Each --exclude flag value may itself contain comma- or
|
||||
// whitespace-separated scopes. Returns the filtered scope string and any
|
||||
// exclude entries that were not present in the requested set (callers can
|
||||
// surface those as a validation error to catch typos like
|
||||
// `--exclude drive:file:downlod`).
|
||||
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
|
||||
requestedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(requested) {
|
||||
requestedSet[s] = true
|
||||
}
|
||||
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, raw := range excludes {
|
||||
// --exclude already splits on commas (StringSliceVar), but also
|
||||
// tolerate whitespace-separated entries inside a single value.
|
||||
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
|
||||
excludeSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for s := range excludeSet {
|
||||
if !requestedSet[s] {
|
||||
unknown = append(unknown, s)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return requested, unknown
|
||||
}
|
||||
|
||||
kept := make(map[string]bool, len(requestedSet))
|
||||
for s := range requestedSet {
|
||||
if !excludeSet[s] {
|
||||
kept[s] = true
|
||||
}
|
||||
}
|
||||
return joinSortedScopeSet(kept), nil
|
||||
}
|
||||
|
||||
@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"single", "vc:note:read", "vc:note:read"},
|
||||
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
|
||||
{"trim_and_dedup", " a , b , a ", "a b"},
|
||||
{"trailing_separators", "a,b,,", "a b"},
|
||||
{"only_separators", " , , ", ""},
|
||||
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeScopeInput(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
|
||||
@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
@@ -169,6 +169,25 @@ func appendUniq(ss []string, s string) []string {
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
out := diagBuild([]string{"drive"})
|
||||
var sawMetadata, sawDownload bool
|
||||
for _, method := range out.Methods {
|
||||
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
|
||||
continue
|
||||
}
|
||||
if method.Scope == "drive:drive.metadata:readonly" {
|
||||
sawMetadata = true
|
||||
}
|
||||
if method.Scope == "drive:file:download" {
|
||||
sawDownload = true
|
||||
}
|
||||
}
|
||||
if !sawMetadata || !sawDownload {
|
||||
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
|
||||
@@ -252,7 +252,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
"run: lark-cli update")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.ScopesForIdentity(identity)
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ func setupNotices() {
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
@@ -147,6 +148,7 @@ func setupNotices() {
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
|
||||
@@ -612,6 +612,9 @@ func TestSetupNotices_Drift(t *testing.T) {
|
||||
if msg, _ := skills["message"].(string); msg != want {
|
||||
t.Errorf("notice.skills.message = %q, want %q", msg, want)
|
||||
}
|
||||
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
|
||||
@@ -658,6 +661,20 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
if _, ok := notice["skills"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'skills' key: %+v", notice)
|
||||
}
|
||||
upd, ok := notice["update"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.update missing or wrong type: %+v", notice)
|
||||
}
|
||||
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
sk, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
|
||||
}
|
||||
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
}
|
||||
|
||||
// clearNoticeEnv unsets the env vars that affect either notice. We
|
||||
|
||||
@@ -284,6 +284,32 @@ func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected error detail")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
|
||||
@@ -481,6 +481,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
|
||||
t.Errorf("expected manual reinstall command in hint, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills will not be synced") {
|
||||
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
|
||||
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_JSON_Npm(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.29",
|
||||
"version": "1.0.31",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -44,6 +44,7 @@ const messages = {
|
||||
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
||||
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等)说:\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
cancelled: "安装已取消",
|
||||
nonTtyHint: "要完成配置,请在终端中运行:\n lark-cli config init --new\n lark-cli auth login",
|
||||
},
|
||||
en: {
|
||||
setup: "Setting up Feishu/Lark CLI...",
|
||||
@@ -72,6 +73,7 @@ const messages = {
|
||||
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
||||
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
|
||||
cancelled: "Installation cancelled",
|
||||
nonTtyHint: "To complete setup, run interactively:\n lark-cli config init --new\n lark-cli auth login",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -353,17 +355,23 @@ async function stepAuthLogin(msg) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const lang = await stepSelectLang();
|
||||
const isInteractive = !!process.stdin.isTTY;
|
||||
const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en");
|
||||
const msg = messages[lang];
|
||||
|
||||
p.intro(msg.setup);
|
||||
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
|
||||
p.outro(msg.done);
|
||||
if (isInteractive) {
|
||||
p.intro(msg.setup);
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
p.outro(msg.done);
|
||||
} else {
|
||||
console.log(msg.setup);
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
console.log(msg.nonTtyHint);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -33,9 +33,18 @@ type Shortcut struct {
|
||||
Command string
|
||||
Description string
|
||||
Risk string // "read" | "write" | "high-risk-write" (empty defaults to "read")
|
||||
Scopes []string // default scopes (fallback when UserScopes/BotScopes are empty)
|
||||
UserScopes []string // optional: user-identity scopes (overrides Scopes when non-empty)
|
||||
BotScopes []string // optional: bot-identity scopes (overrides Scopes when non-empty)
|
||||
Scopes []string // unconditional pre-flight scopes (fallback when UserScopes/BotScopes are empty)
|
||||
UserScopes []string // optional: user-identity unconditional scopes (overrides Scopes when non-empty)
|
||||
BotScopes []string // optional: bot-identity unconditional scopes (overrides Scopes when non-empty)
|
||||
|
||||
// ConditionalScopes are additional scopes that only some execution paths
|
||||
// need (for example a default mode vs. a lighter --quick mode, or a
|
||||
// destructive flag like --delete-remote). They are surfaced in metadata,
|
||||
// auth hints, and scope-diagnosis output via DeclaredScopesForIdentity, but
|
||||
// they are NOT enforced by the framework's unconditional pre-flight check.
|
||||
ConditionalScopes []string // fallback when ConditionalUserScopes/BotScopes are empty
|
||||
ConditionalUserScopes []string // optional: user-identity conditional scopes
|
||||
ConditionalBotScopes []string // optional: bot-identity conditional scopes
|
||||
|
||||
// Declarative fields (new framework).
|
||||
AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
|
||||
@@ -72,3 +81,47 @@ func (s *Shortcut) ScopesForIdentity(identity string) []string {
|
||||
}
|
||||
return s.Scopes
|
||||
}
|
||||
|
||||
// ConditionalScopesForIdentity returns additional flag/path-dependent scopes
|
||||
// for the given identity. Identity-specific conditional scopes override the
|
||||
// default ConditionalScopes when present.
|
||||
func (s *Shortcut) ConditionalScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.ConditionalUserScopes) > 0 {
|
||||
return s.ConditionalUserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.ConditionalBotScopes) > 0 {
|
||||
return s.ConditionalBotScopes
|
||||
}
|
||||
}
|
||||
return s.ConditionalScopes
|
||||
}
|
||||
|
||||
// DeclaredScopesForIdentity returns the full scope set agents/help/diagnostics
|
||||
// should know about for this shortcut: unconditional pre-flight scopes plus
|
||||
// any conditional scopes that some execution paths may require.
|
||||
func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
|
||||
base := s.ScopesForIdentity(identity)
|
||||
extra := s.ConditionalScopesForIdentity(identity)
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
seen := make(map[string]struct{}, len(base)+len(extra))
|
||||
for _, scope := range append(base, extra...) {
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
out = append(out, scope)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -71,3 +71,37 @@ func TestScopesForIdentity_NilScopes(t *testing.T) {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionalScopesForIdentity_FallbackAndOverrides(t *testing.T) {
|
||||
s := Shortcut{
|
||||
ConditionalScopes: []string{"c-default"},
|
||||
ConditionalUserScopes: []string{"c-user"},
|
||||
ConditionalBotScopes: []string{"c-bot"},
|
||||
}
|
||||
if got := s.ConditionalScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"c-user"}) {
|
||||
t.Errorf("expected user conditional scopes, got %v", got)
|
||||
}
|
||||
if got := s.ConditionalScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"c-bot"}) {
|
||||
t.Errorf("expected bot conditional scopes, got %v", got)
|
||||
}
|
||||
if got := s.ConditionalScopesForIdentity("tenant"); !reflect.DeepEqual(got, []string{"c-default"}) {
|
||||
t.Errorf("expected default conditional scopes for unknown identity, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredScopesForIdentity_MergesAndDeduplicates(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Scopes: []string{"base-a", "shared"},
|
||||
ConditionalScopes: []string{"shared", "cond-b"},
|
||||
}
|
||||
if got := s.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"base-a", "shared", "cond-b"}) {
|
||||
t.Errorf("expected merged declared scopes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
|
||||
s := Shortcut{ConditionalScopes: []string{"cond-only"}}
|
||||
if got := s.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"cond-only"}) {
|
||||
t.Errorf("expected conditional-only declared scopes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,19 @@ const docsServiceHelpDefault = `Document and content operations.`
|
||||
const docsServiceHelpV2 = `Document and content operations (v2).`
|
||||
|
||||
var docsVersionSelectionTips = []string{
|
||||
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
|
||||
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
|
||||
"Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
||||
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
|
||||
}
|
||||
|
||||
var docsV2VersionSelectionTips = []string{
|
||||
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
||||
}
|
||||
|
||||
func docsTipsForVersion(apiVersion string) []string {
|
||||
if apiVersion == "v2" {
|
||||
return docsV2VersionSelectionTips
|
||||
}
|
||||
return docsVersionSelectionTips
|
||||
}
|
||||
|
||||
// Shortcuts returns all docs shortcuts.
|
||||
@@ -38,8 +49,7 @@ func Shortcuts() []common.Shortcut {
|
||||
|
||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
||||
// The shortcut-level help remains compatible with legacy v1 skills; this parent
|
||||
// help gives agents enough context to choose v2 only when their installed skill
|
||||
// explicitly asks for `--api-version v2`.
|
||||
// help switches docs guidance to match the selected API version.
|
||||
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
@@ -75,7 +85,7 @@ func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range docsVersionSelectionTips {
|
||||
for _, tip := range docsTipsForVersion(apiVersion) {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ package doc
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
@@ -29,6 +30,7 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
|
||||
f.Hidden = fv != ver
|
||||
}
|
||||
})
|
||||
cmdutil.SetTips(cmd, docsTipsForVersion(ver))
|
||||
origHelp(cmd, args)
|
||||
})
|
||||
}
|
||||
@@ -37,6 +39,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
|
||||
// path is used.
|
||||
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n",
|
||||
shortcut)
|
||||
"[deprecated] docs %s is using the v1 API. %s\n",
|
||||
shortcut, docsV2VersionSelectionTips[0])
|
||||
}
|
||||
|
||||
36
shortcuts/doc/versioned_help_test.go
Normal file
36
shortcuts/doc/versioned_help_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) {
|
||||
for _, shortcut := range []string{"+create", "+fetch", "+update"} {
|
||||
t.Run(shortcut, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut)
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[deprecated] docs " + shortcut + " is using the v1 API.",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("warning missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "will be removed in a future release") {
|
||||
t.Fatalf("warning should not include removal-only guidance:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -625,6 +626,94 @@ func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChooseRemoteFileSortsMixedUnitEpochsByActualTime verifies duplicate
|
||||
// resolution compares actual timestamps rather than raw integer magnitudes when
|
||||
// Drive mixes second- and millisecond-resolution epoch strings.
|
||||
func TestChooseRemoteFileSortsMixedUnitEpochsByActualTime(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_seconds", CreatedTime: "1715594881", ModifiedTime: "1715594881"},
|
||||
{FileToken: "token_millis", CreatedTime: "1715594880123", ModifiedTime: "1715594880123"},
|
||||
}
|
||||
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile newest: %v", err)
|
||||
}
|
||||
if gotNewest.FileToken != "token_seconds" {
|
||||
t.Fatalf("newest token = %q, want token_seconds", gotNewest.FileToken)
|
||||
}
|
||||
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile oldest: %v", err)
|
||||
}
|
||||
if gotOldest.FileToken != "token_millis" {
|
||||
t.Fatalf("oldest token = %q, want token_millis", gotOldest.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits
|
||||
// proves the duplicate selector and delete pass agree on the true newest file
|
||||
// even when remote timestamps use mixed epoch units.
|
||||
func TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1715594880123", "modified_time": "1715594880123"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "1715594881", "modified_time": "1715594881"},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "dup-new-token",
|
||||
"version": "v7",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
|
||||
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
|
||||
}
|
||||
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for older mixed-unit duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
|
||||
@@ -646,6 +735,46 @@ func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDriveRemoteModifiedToLocalSupportsSecondAndMillisecondEpochs(t *testing.T) {
|
||||
t.Run("second resolution truncates local mtime", func(t *testing.T) {
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal("100", time.Unix(100, 900*int64(time.Millisecond)))
|
||||
if !ok {
|
||||
t.Fatal("expected second-resolution timestamp to parse")
|
||||
}
|
||||
if cmp != 0 {
|
||||
t.Fatalf("cmp = %d, want 0 when local only differs below second resolution", cmp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("millisecond resolution stays precise", func(t *testing.T) {
|
||||
const remoteMillis = int64(1715594880123)
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMillis, 10), time.UnixMilli(remoteMillis))
|
||||
if !ok {
|
||||
t.Fatal("expected millisecond-resolution timestamp to parse")
|
||||
}
|
||||
if cmp != 0 {
|
||||
t.Fatalf("cmp = %d, want 0 for equal millisecond timestamps", cmp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("microsecond resolution stays precise", func(t *testing.T) {
|
||||
const remoteMicros = int64(1715594880123456)
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMicros, 10), time.UnixMicro(remoteMicros))
|
||||
if !ok {
|
||||
t.Fatal("expected microsecond-resolution timestamp to parse")
|
||||
}
|
||||
if cmp != 0 {
|
||||
t.Fatalf("cmp = %d, want 0 for equal microsecond timestamps", cmp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid timestamp is rejected", func(t *testing.T) {
|
||||
if _, ok := compareDriveRemoteModifiedToLocal("not-a-time", time.Now()); ok {
|
||||
t.Fatal("expected invalid remote timestamp to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
|
||||
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
|
||||
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},
|
||||
|
||||
@@ -228,6 +228,206 @@ func TestDriveUploadLargeFileToWikiUsesMultipart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadLargeFileOverwriteUsesMultipart(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-large-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
|
||||
"block_num": float64(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_multipart_overwrite_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "large.bin",
|
||||
"--file-token", "box_existing_large_upload",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, prepareStub)
|
||||
if got := body["file_token"]; got != "box_existing_large_upload" {
|
||||
t.Fatalf("file_token = %#v, want %q", got, "box_existing_large_upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinish(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-large-overwrite-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_multipart_overwrite_version_token",
|
||||
"version": "v44",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "large.bin",
|
||||
"--file-token", "box_existing_large_upload",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["version"]; got != "v44" {
|
||||
t.Fatalf("data.version = %#v, want %q", got, "v44")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinishAlias(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-large-overwrite-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_multipart_overwrite_alias_token",
|
||||
"data_version": "v45",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("large.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "large.bin",
|
||||
"--file-token", "box_existing_large_upload",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["version"]; got != "v45" {
|
||||
t.Fatalf("data.version = %#v, want %q", got, "v45")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadSmallFile(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -267,6 +467,93 @@ func TestDriveUploadSmallFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadSmallFileOverwriteUsesFileToken(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-small-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_small_overwrite_token",
|
||||
"version": "v42",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "small.bin",
|
||||
"--file-token", "box_existing_small_upload",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected small overwrite upload to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, stub)
|
||||
if got := body.Fields["file_token"]; got != "box_existing_small_upload" {
|
||||
t.Fatalf("file_token = %q, want %q", got, "box_existing_small_upload")
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["version"]; got != "v42" {
|
||||
t.Fatalf("data.version = %#v, want %q", got, "v42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadReturnsVersionFromDataVersionAlias(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-small-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_small_alias_token",
|
||||
"data_version": "v43",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "small.bin",
|
||||
"--file-token", "box_existing_alias_upload",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected overwrite upload to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["version"]; got != "v43" {
|
||||
t.Fatalf("data.version = %#v, want %q", got, "v43")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadSmallFileToWiki(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-small-wiki-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -767,6 +1054,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
@@ -812,6 +1100,7 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
@@ -821,6 +1110,9 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
if err := cmd.Flags().Set("folder-token", " fld_upload_target "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file-token", " box_upload_target "); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("wiki-token", " wikcn_upload_target "); err != nil {
|
||||
t.Fatalf("set --wiki-token: %v", err)
|
||||
}
|
||||
@@ -839,11 +1131,108 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
if got.FolderToken != "fld_upload_target" {
|
||||
t.Fatalf("FolderToken = %q, want trimmed token", got.FolderToken)
|
||||
}
|
||||
if got.FileToken != "box_upload_target" {
|
||||
t.Fatalf("FileToken = %q, want trimmed token", got.FileToken)
|
||||
}
|
||||
if got.WikiToken != "wikcn_upload_target" {
|
||||
t.Fatalf("WikiToken = %q, want trimmed token", got.WikiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "./report.pdf"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveUpload.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
|
||||
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("as", "", "")
|
||||
if err := cmd.Flags().Set("file", "./report.pdf"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatalf("set --as: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveUpload.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
|
||||
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
|
||||
}
|
||||
if strings.Contains(got.API[0].Desc, "grant the current CLI user full_access") {
|
||||
t.Fatalf("dry-run desc should skip permission-grant hint for overwrite, got %q", got.API[0].Desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadTargetLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -901,6 +1290,7 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) {
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
@@ -923,6 +1313,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
@@ -940,11 +1331,35 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
if err := cmd.Flags().Set("file", "report.pdf"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file-token", " "); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
err := DriveUpload.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") {
|
||||
t.Fatalf("Validate() error = %v, want empty file-token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
@@ -983,6 +1398,12 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) {
|
||||
value: "wikcn_bad#fragment",
|
||||
wantErr: "--wiki-token contains invalid characters",
|
||||
},
|
||||
{
|
||||
name: "file token",
|
||||
flag: "file-token",
|
||||
value: "box_bad?query=true",
|
||||
wantErr: "--file-token contains invalid characters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -991,6 +1412,7 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) {
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("wiki-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
|
||||
@@ -75,6 +75,48 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadBotOverwriteSkipsPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_uploaded",
|
||||
"version": "v2",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("report.pdf", []byte("pdf"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "report.pdf",
|
||||
"--file-token", "file_uploaded",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant for overwrite output: %#v", data)
|
||||
}
|
||||
if got := data["version"]; got != "v2" {
|
||||
t.Fatalf("version = %#v, want %q", got, "v2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportBotAutoGrantSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -20,8 +21,18 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var drivePullChtimes = drivePullApplyChtimes
|
||||
|
||||
// drivePullApplyChtimes is a tiny indirection that keeps the production path on
|
||||
// os.Chtimes while still letting tests inject mtime failures without requiring a
|
||||
// custom filesystem implementation.
|
||||
func drivePullApplyChtimes(path string, atime, mtime time.Time) error {
|
||||
return os.Chtimes(path, atime, mtime) //nolint:forbidigo // FileIO exposes no mtime mutation API yet; callers resolve and bound the path first.
|
||||
}
|
||||
|
||||
const (
|
||||
drivePullIfExistsOverwrite = "overwrite"
|
||||
drivePullIfExistsSmart = "smart"
|
||||
drivePullIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
@@ -37,6 +48,7 @@ type drivePullTarget struct {
|
||||
DownloadToken string
|
||||
ItemFileToken string
|
||||
ItemSourceID string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
@@ -60,7 +72,7 @@ var DrivePull = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists (skip = never touch existing files; smart = skip when local mtime is already up to date; overwrite = always replace)", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSmart, drivePullIfExistsSkip}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
|
||||
@@ -68,6 +80,7 @@ var DrivePull = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
|
||||
"For repeat syncs, --if-exists=smart is the recommended best-effort incremental mode: it compares local mtime with Drive modified_time and skips downloads when the local copy is already up to date.",
|
||||
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
|
||||
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
},
|
||||
@@ -202,14 +215,14 @@ var DrivePull = common.Shortcut{
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
if ifExists == drivePullIfExistsSkip || drivePullShouldSkipSmart(target, targetFile, ifExists, runtime) {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil {
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
@@ -305,7 +318,9 @@ var DrivePull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
@@ -320,9 +335,53 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
}, resp.Body); err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePullApplyRemoteModifiedTime preserves Drive's modified_time on a local
|
||||
// file when the remote timestamp is parseable and the target path is safe.
|
||||
func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime *common.RuntimeContext) error {
|
||||
remoteTime, _, ok := parseDriveEpoch(remoteModifiedTime)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(target)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePullShouldSkipSmart(target string, remoteFile drivePullTarget, ifExists string, runtime *common.RuntimeContext) bool {
|
||||
if ifExists != drivePullIfExistsSmart {
|
||||
return false
|
||||
}
|
||||
if remoteFile.ModifiedTime == "" {
|
||||
return false
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(resolved) //nolint:forbidigo // FileIO exposes no ModTime-capable Stat; ResolvePath already bounded the path.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, info.ModTime())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Local is already at least as new as the remote file, so another
|
||||
// download would be redundant.
|
||||
return cmp <= 0
|
||||
}
|
||||
|
||||
func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) {
|
||||
remoteFiles := make(map[string]drivePullTarget, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
@@ -346,7 +405,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
for _, rel := range relPaths {
|
||||
files := fileGroups[rel]
|
||||
if len(files) == 1 {
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken, ModifiedTime: files[0].ModifiedTime}
|
||||
remotePaths[rel] = struct{}{}
|
||||
continue
|
||||
}
|
||||
@@ -366,6 +425,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
remoteFiles[targetRel] = drivePullTarget{
|
||||
DownloadToken: file.FileToken,
|
||||
ItemSourceID: stableTokenIdentifier(file.FileToken),
|
||||
ModifiedTime: file.ModifiedTime,
|
||||
}
|
||||
remotePaths[targetRel] = struct{}{}
|
||||
}
|
||||
@@ -374,7 +434,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime}
|
||||
remotePaths[rel] = struct{}{}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
|
||||
@@ -4,17 +4,23 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestDrivePullDownloadsAndCreatesParents verifies the happy path: a remote
|
||||
@@ -151,6 +157,322 @@ func TestDrivePullSkipsExistingWhenSkipPolicy(t *testing.T) {
|
||||
mustReadFile(t, filepath.Join("local", "keep.txt"), "local-original")
|
||||
}
|
||||
|
||||
// TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate verifies the
|
||||
// smart fast path for Drive → local mirrors: when the local copy is already
|
||||
// at least as new as the remote file, +pull skips the download.
|
||||
func TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Intentionally NO download stub: smart mode should skip the transfer.
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"downloaded": 0`) {
|
||||
t.Errorf("expected downloaded=0, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, localPath, "hello")
|
||||
}
|
||||
|
||||
// TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer verifies the smart
|
||||
// policy still downloads when the remote file is newer than the local copy.
|
||||
func TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_keep/download",
|
||||
Status: 200,
|
||||
Body: []byte("WORLD"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"downloaded": 1`) {
|
||||
t.Errorf("expected downloaded=1, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, localPath, "WORLD")
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if got, want := info.ModTime(), time.Unix(200, 0); !got.Equal(want) {
|
||||
t.Fatalf("local mtime = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePullTreatsModifiedTimePreservationFailureAsNotice verifies a local
|
||||
// write that succeeds but cannot preserve remote modified_time still reports a
|
||||
// successful download and only emits an operator-facing notice on stderr.
|
||||
func TestDrivePullTreatsModifiedTimePreservationFailureAsNotice(t *testing.T) {
|
||||
f, stdout, stderrBuf, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
prevChtimes := drivePullChtimes
|
||||
drivePullChtimes = func(string, time.Time, time.Time) error {
|
||||
return fmt.Errorf("mtime mutation unsupported")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
drivePullChtimes = prevChtimes
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_keep/download",
|
||||
Status: 200,
|
||||
Body: []byte("WORLD"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-local",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderrBuf.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"downloaded": 1`) {
|
||||
t.Errorf("expected downloaded=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"failed": 0`) {
|
||||
t.Errorf("expected failed=0, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, filepath.Join("local", "keep.txt"), "WORLD")
|
||||
if !strings.Contains(stderrBuf.String(), "could not preserve remote modified_time") {
|
||||
t.Errorf("expected stderr notice about modified_time preservation failure, got: %s", stderrBuf.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
ifExists string
|
||||
remoteFile drivePullTarget
|
||||
}{
|
||||
{
|
||||
name: "non-smart policy",
|
||||
ifExists: drivePullIfExistsOverwrite,
|
||||
remoteFile: drivePullTarget{ModifiedTime: "100"},
|
||||
},
|
||||
{
|
||||
name: "missing remote timestamp",
|
||||
ifExists: drivePullIfExistsSmart,
|
||||
remoteFile: drivePullTarget{ModifiedTime: ""},
|
||||
},
|
||||
{
|
||||
name: "invalid remote timestamp",
|
||||
ifExists: drivePullIfExistsSmart,
|
||||
remoteFile: drivePullTarget{ModifiedTime: "not-a-time"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := drivePullShouldSkipSmart(localPath, tt.remoteFile, tt.ifExists, runtime); got {
|
||||
t.Fatalf("drivePullShouldSkipSmart() = true, want false for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullShouldSkipSmartFallsBackWhenPathCannotBeResolved(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
|
||||
|
||||
if got := drivePullShouldSkipSmart("../escape.txt", drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
|
||||
t.Fatal("drivePullShouldSkipSmart() = true, want false when ResolvePath rejects the target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullShouldSkipSmartFallsBackWhenLocalFileDisappeared(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
|
||||
|
||||
if got := drivePullShouldSkipSmart(filepath.Join("local", "missing.txt"), drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
|
||||
t.Fatal("drivePullShouldSkipSmart() = true, want false when os.Stat cannot find the local file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"downloaded": 0`) {
|
||||
t.Errorf("expected downloaded=0, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, localPath, "hello")
|
||||
}
|
||||
|
||||
// TestDrivePullSurfacesDirectoryFileMirrorConflict pins the contract
|
||||
// for the case where Drive ships a regular file at a rel_path that is
|
||||
// already a directory locally. SafeOutputPath would refuse to overwrite
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
|
||||
const (
|
||||
drivePushIfExistsOverwrite = "overwrite"
|
||||
drivePushIfExistsSmart = "smart"
|
||||
drivePushIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
@@ -91,7 +93,7 @@ var DrivePush = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, drivePushIfExistsSkip}},
|
||||
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
|
||||
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
||||
@@ -99,8 +101,9 @@ var DrivePush = common.Shortcut{
|
||||
Tips: []string{
|
||||
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
||||
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
||||
"For repeat syncs, --if-exists=smart is a best-effort incremental mode: it compares local mtime with Drive modified_time and skips uploads when the remote copy is already up to date; otherwise it falls through to the same overwrite path as --if-exists=overwrite.",
|
||||
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero. The same caveat applies when --if-exists=smart decides the remote file is older and falls through to overwrite.",
|
||||
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
||||
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
|
||||
@@ -151,7 +154,7 @@ var DrivePush = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, skip existing, skip up-to-date files when --if-exists=smart, overwrite when --if-exists=overwrite, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
@@ -267,7 +270,7 @@ var DrivePush = common.Shortcut{
|
||||
localFile := localFiles[rel]
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if ifExists == drivePushIfExistsSkip {
|
||||
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
|
||||
skipped++
|
||||
continue
|
||||
@@ -394,6 +397,7 @@ type drivePushLocalFile struct {
|
||||
OpenPath string
|
||||
FileName string
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// drivePushWalkLocal walks the canonical absolute root produced by
|
||||
@@ -450,6 +454,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
OpenPath: relToCwd,
|
||||
FileName: filepath.Base(rel),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -473,6 +478,30 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
func drivePushShouldSkipExisting(localFile drivePushLocalFile, remoteFile driveRemoteEntry, ifExists string) bool {
|
||||
switch ifExists {
|
||||
case drivePushIfExistsSkip:
|
||||
return true
|
||||
case drivePushIfExistsSmart:
|
||||
return drivePushShouldSkipSmart(localFile, remoteFile)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemoteEntry) bool {
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, localFile.ModTime)
|
||||
if !ok {
|
||||
// Smart mode is an optimization. If the timestamp is missing or
|
||||
// malformed, fall back to the safe transfer path instead of silently
|
||||
// skipping an update we could not compare.
|
||||
return false
|
||||
}
|
||||
// Remote is already at least as new as the local file, so another
|
||||
// upload would be redundant.
|
||||
return cmp >= 0
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -324,6 +325,203 @@ func TestDrivePushSkipsWhenIfExistsSkip(t *testing.T) {
|
||||
// would 404 against the registry and the run would have errored above.
|
||||
}
|
||||
|
||||
// TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate verifies the smart
|
||||
// fast path for local → Drive mirrors: when the remote copy is already at
|
||||
// least as new as the local file, +push skips the upload.
|
||||
func TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Intentionally NO upload_all stub: smart mode should skip the transfer.
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"uploaded": 0`) {
|
||||
t.Errorf("expected uploaded=0, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer verifies the smart
|
||||
// path still uploads when the local file is newer than the remote one.
|
||||
func TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "tok_keep_new", "version": "v43"},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"uploaded": 1`) {
|
||||
t.Errorf("expected uploaded=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"action": "overwritten"`) {
|
||||
t.Errorf("expected overwritten action, got: %s", out)
|
||||
}
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != "tok_keep_old" {
|
||||
t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
localFile := drivePushLocalFile{
|
||||
Size: 5,
|
||||
ModTime: time.Unix(100, 500*int64(time.Millisecond)),
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
remoteFile driveRemoteEntry
|
||||
}{
|
||||
{
|
||||
name: "invalid remote timestamp",
|
||||
remoteFile: driveRemoteEntry{ModifiedTime: "not-a-time"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := drivePushShouldSkipSmart(localFile, tt.remoteFile); got {
|
||||
t.Fatalf("drivePushShouldSkipSmart() = true, want false for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"uploaded": 0`) {
|
||||
t.Errorf("expected uploaded=0, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushDeleteRemoteRequiresYes locks in the upfront safety guard:
|
||||
// --delete-remote without --yes must be refused before any list / upload
|
||||
// happens, so a stray flag never silently deletes anything.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -26,8 +27,24 @@ type driveStatusEntry struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
}
|
||||
|
||||
type driveStatusLocalFile struct {
|
||||
PathToCwd string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type driveStatusRemoteFile struct {
|
||||
FileToken string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
const (
|
||||
driveStatusDetectionExact = "exact"
|
||||
driveStatusDetectionQuick = "quick"
|
||||
)
|
||||
|
||||
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
|
||||
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
|
||||
// four buckets (new_local, new_remote, modified, unchanged) either by exact
|
||||
// SHA-256 hash (default) or by a quick modified_time comparison (--quick).
|
||||
//
|
||||
// Only Drive entries with type=file are compared; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
@@ -37,19 +54,22 @@ type driveStatusEntry struct {
|
||||
// path that resolves outside cwd, which keeps the local side bounded to the
|
||||
// caller's working directory.
|
||||
var DriveStatus = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+status",
|
||||
Description: "Compare a local directory with a Drive folder by content hash",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Service: "drive",
|
||||
Command: "+status",
|
||||
Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
ConditionalScopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "Drive folder token", Required: true},
|
||||
{Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
|
||||
"Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.",
|
||||
"Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
@@ -77,17 +97,37 @@ var DriveStatus = common.Shortcut{
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
// Conditional scope pre-check: quick mode only compares local mtime with
|
||||
// Drive modified_time, so it must not be blocked on the download grant.
|
||||
// Exact mode hashes remote bytes, which requires drive:file:download. Do
|
||||
// the stricter check here once we know which execution path the flags
|
||||
// selected. EnsureScopes is a silent no-op when scope metadata is
|
||||
// unavailable, so environments without token scope introspection still
|
||||
// proceed and rely on the API-level missing_scope error if needed.
|
||||
if !runtime.Bool("quick") {
|
||||
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."
|
||||
if runtime.Bool("quick") {
|
||||
desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes."
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
|
||||
Desc(desc).
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
detection := driveStatusDetectionExact
|
||||
if runtime.Bool("quick") {
|
||||
detection = driveStatusDetectionQuick
|
||||
}
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
@@ -112,7 +152,7 @@ var DriveStatus = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
|
||||
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,30 +170,42 @@ var DriveStatus = common.Shortcut{
|
||||
// hashable bytes and are intentionally absent from the diff
|
||||
// view (a docx living next to a same-named local file is a
|
||||
// known no-op).
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[entry.RelPath] = entry.FileToken
|
||||
remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime}
|
||||
}
|
||||
}
|
||||
|
||||
paths := mergeStatusPaths(localHashes, remoteFiles)
|
||||
paths := mergeStatusPaths(localFiles, remoteFiles)
|
||||
|
||||
var newLocal, newRemote, modified, unchanged []driveStatusEntry
|
||||
for _, relPath := range paths {
|
||||
localHash, hasLocal := localHashes[relPath]
|
||||
remoteToken, hasRemote := remoteFiles[relPath]
|
||||
localFile, hasLocal := localFiles[relPath]
|
||||
remoteFile, hasRemote := remoteFiles[relPath]
|
||||
switch {
|
||||
case hasLocal && !hasRemote:
|
||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||
case !hasLocal && hasRemote:
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
|
||||
default:
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
|
||||
if detection == driveStatusDetectionQuick {
|
||||
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
|
||||
if localHash == remoteHash {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
@@ -163,6 +215,7 @@ var DriveStatus = common.Shortcut{
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"detection": detection,
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
@@ -180,8 +233,8 @@ var DriveStatus = common.Shortcut{
|
||||
// hit, we report rel_path relative to root for the JSON output, and
|
||||
// convert the absolute path to a cwd-relative form so FileIO.Open's
|
||||
// SafeInputPath check (which rejects absolute paths) still applies.
|
||||
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) {
|
||||
files := make(map[string]driveStatusLocalFile)
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
||||
// The walk root is the canonical absolute path returned by
|
||||
// validate.SafeInputPath, so it is no longer a symlink itself, and
|
||||
@@ -202,11 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, err := hashLocalForStatus(runtime, relToCwd)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[filepath.ToSlash(rel)] = sum
|
||||
files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -215,6 +268,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Time) bool {
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(remoteModified, local)
|
||||
return ok && cmp == 0
|
||||
}
|
||||
|
||||
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
@@ -244,7 +302,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func mergeStatusPaths(local, remote map[string]string) []string {
|
||||
func mergeStatusPaths(local map[string]driveStatusLocalFile, remote map[string]driveStatusRemoteFile) []string {
|
||||
seen := make(map[string]struct{}, len(local)+len(remote))
|
||||
for p := range local {
|
||||
seen[p] = struct{}{}
|
||||
|
||||
@@ -4,16 +4,32 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// driveStatusScopedTokenResolver returns a token with caller-controlled scopes
|
||||
// so tests can deterministically exercise the shortcut scope preflight.
|
||||
type driveStatusScopedTokenResolver struct {
|
||||
scopes string
|
||||
}
|
||||
|
||||
// ResolveToken satisfies credential.TokenProvider for scope-preflight tests.
|
||||
func (r *driveStatusScopedTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
|
||||
}
|
||||
|
||||
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
|
||||
// against a real walk of the temp dir and a mocked Drive listing.
|
||||
func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
@@ -105,6 +121,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"detection": "exact"`) {
|
||||
t.Fatalf("output missing detection=exact\noutput: %s", out)
|
||||
}
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
@@ -134,6 +153,264 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.MkdirAll("local/sub", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/sub/c.txt", []byte("local-c"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
matchTime := time.Unix(1715594880, 0)
|
||||
changedTime := time.Unix(1715594940, 0)
|
||||
if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil {
|
||||
t.Fatalf("Chtimes a.txt: %v", err)
|
||||
}
|
||||
if err := os.Chtimes("local/sub/c.txt", changedTime, changedTime); err != nil {
|
||||
t.Fatalf("Chtimes sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"},
|
||||
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
|
||||
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=tok_sub",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file", "modified_time": "1715594880"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"detection": "quick"`) {
|
||||
t.Fatalf("output missing detection=quick\noutput: %s", out)
|
||||
}
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string
|
||||
}{
|
||||
{"new_local", "b.txt", ""},
|
||||
{"new_remote", "d.txt", "tok_d"},
|
||||
{"modified", "sub/c.txt", "tok_c"},
|
||||
{"unchanged", "a.txt", "tok_a"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(out, `"`+c.bucket+`":`) {
|
||||
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
|
||||
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
|
||||
}
|
||||
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
|
||||
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
|
||||
}
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusQuickMarksUntrustedTimestampAsModified locks in the
|
||||
// conservative fallback for malformed remote modified_time values.
|
||||
func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"detection": "quick"`) {
|
||||
t.Fatalf("output missing detection=quick\noutput: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"rel_path": "a.txt"`) {
|
||||
t.Fatalf("invalid remote modified_time must fall back to modified\noutput: %s", out)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusExactRejectsMissingDownloadScope proves that exact mode keeps
|
||||
// requiring drive:file:download even after quick mode made download optional.
|
||||
func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing_scope error for exact mode without drive:file:download")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" {
|
||||
t.Fatalf("expected missing_scope detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing required scope(s): drive:file:download") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Hint, "auth login --scope") {
|
||||
t.Fatalf("missing scope hint not found in detail: %#v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "drive:file:download") {
|
||||
t.Fatalf("error should mention drive:file:download: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusQuickAcceptsMissingDownloadScope ensures quick mode is not
|
||||
// blocked on the exact-mode download scope precheck.
|
||||
func TestDriveStatusQuickAcceptsMissingDownloadScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("quick mode should not require drive:file:download: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"detection": "quick"`) {
|
||||
t.Fatalf("output missing detection=quick\noutput: %s", stdout.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusShouldTreatAsUnchangedQuick exercises the tiny quick helper
|
||||
// directly so Codecov also sees coverage on the helper body itself.
|
||||
func TestDriveStatusShouldTreatAsUnchangedQuick(t *testing.T) {
|
||||
t.Run("matching timestamp returns true", func(t *testing.T) {
|
||||
if !driveStatusShouldTreatAsUnchangedQuick("1715594880", time.Unix(1715594880, 500)) {
|
||||
t.Fatal("expected matching second-resolution timestamps to be unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different timestamp returns false", func(t *testing.T) {
|
||||
if driveStatusShouldTreatAsUnchangedQuick("1715594881", time.Unix(1715594880, 0)) {
|
||||
t.Fatal("expected different timestamps to be treated as modified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid timestamp returns false", func(t *testing.T) {
|
||||
if driveStatusShouldTreatAsUnchangedQuick("not-a-timestamp", time.Unix(1715594880, 0)) {
|
||||
t.Fatal("expected invalid timestamp to be treated as modified")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
|
||||
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
||||
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
|
||||
type driveUploadSpec struct {
|
||||
FilePath string
|
||||
FileToken string
|
||||
FolderToken string
|
||||
WikiToken string
|
||||
Name string
|
||||
@@ -37,9 +38,15 @@ type driveUploadTarget struct {
|
||||
ParentNode string
|
||||
}
|
||||
|
||||
type driveUploadResult struct {
|
||||
FileToken string
|
||||
Version string
|
||||
}
|
||||
|
||||
func newDriveUploadSpec(runtime *common.RuntimeContext) driveUploadSpec {
|
||||
return driveUploadSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
Name: runtime.Str("name"),
|
||||
@@ -89,6 +96,7 @@ var DriveUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "file-token", Desc: "existing file token to overwrite in place"},
|
||||
{Name: "folder-token", Desc: "target folder token (default: root folder; mutually exclusive with --wiki-token)"},
|
||||
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
|
||||
{Name: "name", Desc: "uploaded file name (default: local file name)"},
|
||||
@@ -96,6 +104,8 @@ var DriveUpload = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
|
||||
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
|
||||
@@ -103,22 +113,28 @@ var DriveUpload = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveUploadSpec(runtime)
|
||||
target := spec.Target()
|
||||
isOverwrite := spec.FileToken != ""
|
||||
body := map[string]interface{}{
|
||||
"file_name": spec.FileName(),
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"file": "@" + spec.FilePath,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName(),
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"file": "@" + spec.FilePath,
|
||||
})
|
||||
if runtime.IsBot() {
|
||||
Body(body)
|
||||
if runtime.IsBot() && !isOverwrite {
|
||||
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveUploadSpec(runtime)
|
||||
isOverwrite := spec.FileToken != ""
|
||||
fileName := spec.FileName()
|
||||
target := spec.Target()
|
||||
|
||||
@@ -130,32 +146,37 @@ var DriveUpload = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> %s\n", fileName, common.FormatSize(fileSize), target.Label())
|
||||
|
||||
var fileToken string
|
||||
var uploadResult driveUploadResult
|
||||
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
fileToken, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize)
|
||||
uploadResult, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken)
|
||||
} else {
|
||||
fileToken, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize)
|
||||
uploadResult, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_token": uploadResult.FileToken,
|
||||
"file_name": fileName,
|
||||
"size": fileSize,
|
||||
}
|
||||
if uploadResult.Version != "" {
|
||||
out["version"] = uploadResult.Version
|
||||
}
|
||||
// wiki-hosted files have no standalone /file/<token> URL — only the
|
||||
// wiki node URL, which the upload response doesn't carry. Skip the
|
||||
// fallback for parent_type=wiki rather than emit a link that 404s.
|
||||
if target.ParentType == driveUploadParentTypeExplorer {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
if !isOverwrite {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
@@ -164,6 +185,9 @@ var DriveUpload = common.Shortcut{
|
||||
}
|
||||
|
||||
func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error {
|
||||
if driveUploadFlagExplicitlyEmpty(runtime, "file-token") {
|
||||
return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite")
|
||||
}
|
||||
if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token")
|
||||
}
|
||||
@@ -191,6 +215,11 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -200,10 +229,10 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str
|
||||
strings.TrimSpace(runtime.Str(flagName)) == ""
|
||||
}
|
||||
|
||||
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) {
|
||||
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return driveUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -213,6 +242,9 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if existingFileToken != "" {
|
||||
fd.AddField("file_token", existingFileToken)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
@@ -223,34 +255,37 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
return "", output.ErrNetwork("upload failed: %v", err)
|
||||
return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
fileToken := common.GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
return fileToken, nil
|
||||
return driveUploadResult{
|
||||
FileToken: fileToken,
|
||||
Version: driveUploadVersionFromData(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// uploadFileMultipart uploads a large file using the three-step multipart API:
|
||||
// 1. upload_prepare — get upload_id, block_size, block_num
|
||||
// 2. upload_part — upload each block sequentially
|
||||
// 3. upload_finish — finalize and get file_token
|
||||
func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) {
|
||||
// 3. upload_finish — finalize and get file_token/version
|
||||
func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
|
||||
// Step 1: Prepare
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
@@ -258,9 +293,12 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
}
|
||||
if existingFileToken != "" {
|
||||
prepareBody["file_token"] = existingFileToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
|
||||
uploadID := common.GetString(prepareResult, "upload_id")
|
||||
@@ -270,7 +308,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
blockNum := int(blockNumF)
|
||||
|
||||
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error",
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
@@ -288,7 +326,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
|
||||
partFile, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return driveUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
@@ -306,18 +344,18 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
|
||||
return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
|
||||
}
|
||||
|
||||
var partResult map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
}
|
||||
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
|
||||
msg, _ := partResult["msg"].(string)
|
||||
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
@@ -330,13 +368,24 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
}
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
|
||||
fileToken := common.GetString(finishResult, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
|
||||
return fileToken, nil
|
||||
return driveUploadResult{
|
||||
FileToken: fileToken,
|
||||
Version: driveUploadVersionFromData(finishResult),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func driveUploadVersionFromData(data map[string]interface{}) string {
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -195,6 +196,9 @@ const (
|
||||
driveDuplicateRemoteOldest = "oldest"
|
||||
)
|
||||
|
||||
// sortRemoteFiles orders duplicate Drive files according to the conflict
|
||||
// strategy, using parsed Drive timestamps so mixed second/millisecond/
|
||||
// microsecond epochs compare by actual time rather than raw integer width.
|
||||
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
a, b := files[i], files[j]
|
||||
@@ -226,16 +230,61 @@ func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
||||
})
|
||||
}
|
||||
|
||||
// compareDriveTimes compares two Drive epoch strings after normalizing their
|
||||
// unit (seconds, milliseconds, or microseconds) into time.Time values.
|
||||
func compareDriveTimes(a, b string) (int, bool) {
|
||||
av, aErr := strconv.ParseInt(a, 10, 64)
|
||||
bv, bErr := strconv.ParseInt(b, 10, 64)
|
||||
if aErr != nil || bErr != nil {
|
||||
at, _, aOK := parseDriveEpoch(a)
|
||||
bt, _, bOK := parseDriveEpoch(b)
|
||||
if !aOK || !bOK {
|
||||
return 0, false
|
||||
}
|
||||
switch {
|
||||
case av < bv:
|
||||
case at.Before(bt):
|
||||
return -1, true
|
||||
case av > bv:
|
||||
case at.After(bt):
|
||||
return 1, true
|
||||
default:
|
||||
return 0, true
|
||||
}
|
||||
}
|
||||
|
||||
func parseDriveEpoch(raw string) (time.Time, time.Duration, bool) {
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, 0, false
|
||||
}
|
||||
// Drive timestamps are epoch strings. The API currently returns
|
||||
// milliseconds, but tests and older payloads may still use seconds.
|
||||
// Infer the unit conservatively from magnitude and compare local mtimes
|
||||
// at the same resolution so sub-second filesystem noise does not force
|
||||
// a transfer in smart mode.
|
||||
switch {
|
||||
case v > 1e14 || v < -1e14:
|
||||
return time.UnixMicro(v), time.Microsecond, true
|
||||
case v > 1e11 || v < -1e11:
|
||||
return time.UnixMilli(v), time.Millisecond, true
|
||||
default:
|
||||
return time.Unix(v, 0), time.Second, true
|
||||
}
|
||||
}
|
||||
|
||||
// compareDriveRemoteModifiedToLocal compares one Drive modified_time string to a
|
||||
// local file mtime.
|
||||
// - returns -1 when remote < local
|
||||
// - returns 0 when remote == local at the remote timestamp resolution
|
||||
// - returns 1 when remote > local
|
||||
//
|
||||
// The bool reports whether the remote timestamp was parseable.
|
||||
func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (int, bool) {
|
||||
remoteTime, resolution, ok := parseDriveEpoch(remoteModified)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
localAtRemoteResolution := local.Truncate(resolution)
|
||||
switch {
|
||||
case remoteTime.Before(localAtRemoteResolution):
|
||||
return -1, true
|
||||
case remoteTime.After(localAtRemoteResolution):
|
||||
return 1, true
|
||||
default:
|
||||
return 0, true
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error.
|
||||
func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -25,6 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
|
||||
// command whose flags are populated from the provided string and bool maps,
|
||||
// for unit-testing shortcut bodies, validators, and dry-run shapes.
|
||||
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -55,6 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext is the messages-search variant of
|
||||
// newTestRuntimeContext: registers the search-specific --page-size flag
|
||||
// before applying caller-provided values.
|
||||
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -86,6 +93,8 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestBuildCreateChatBody verifies the request body assembled when every
|
||||
// flag is populated, including the default chat_mode="group".
|
||||
func TestBuildCreateChatBody(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"type": "public",
|
||||
@@ -94,11 +103,13 @@ func TestBuildCreateChatBody(t *testing.T) {
|
||||
"users": "ou_1, ou_2",
|
||||
"bots": "cli_1, cli_2",
|
||||
"owner": "ou_owner",
|
||||
"chat-mode": "group",
|
||||
}, nil)
|
||||
|
||||
got := buildCreateChatBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"chat_type": "public",
|
||||
"chat_mode": "group",
|
||||
"name": "Team Chat",
|
||||
"description": "daily sync",
|
||||
"user_id_list": []string{
|
||||
@@ -116,6 +127,43 @@ func TestBuildCreateChatBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces
|
||||
// chat_mode="topic" in the request body, the topic-chat creation path.
|
||||
func TestBuildCreateChatBody_TopicMode(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"type": "public",
|
||||
"name": "Topic Group",
|
||||
"chat-mode": "topic",
|
||||
}, nil)
|
||||
|
||||
got := buildCreateChatBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"chat_type": "public",
|
||||
"chat_mode": "topic",
|
||||
"name": "Topic Group",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback:
|
||||
// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty
|
||||
// values), but buildCreateChatBody must still emit chat_mode="group" rather
|
||||
// than an empty string with unspecified server semantics.
|
||||
func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"type": "public",
|
||||
"name": "Fallback Test",
|
||||
"chat-mode": "",
|
||||
}, nil)
|
||||
|
||||
got := buildCreateChatBody(runtime)
|
||||
if got["chat_mode"] != "group" {
|
||||
t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17]
|
||||
func TestSplitMembers(t *testing.T) {
|
||||
got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ")
|
||||
@@ -591,10 +639,12 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
|
||||
// produces the expected API path, query parameters, and request body.
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"type", "name", "users", "owner"} {
|
||||
for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
cmd.Flags().Bool("set-bot-manager", false, "")
|
||||
@@ -604,9 +654,10 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
_ = cmd.Flags().Set("users", "ou_1,ou_2")
|
||||
_ = cmd.Flags().Set("owner", "ou_owner")
|
||||
_ = cmd.Flags().Set("set-bot-manager", "true")
|
||||
_ = cmd.Flags().Set("chat-mode", "group")
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot")
|
||||
got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) {
|
||||
t.Fatalf("ImChatCreate.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
@@ -623,6 +674,25 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
}, map[string]bool{
|
||||
"exclude-muted": true,
|
||||
})
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
// Filter is client-side; --exclude-muted must NOT mutate request body or auto-inject search_types.
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() missing endpoint: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"exclude_muted"`) || strings.Contains(got, `"exclude-muted"`) {
|
||||
t.Fatalf("--exclude-muted leaked into request: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"search_types"`) {
|
||||
t.Fatalf("search_types must not be auto-injected by --exclude-muted: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "incident",
|
||||
@@ -758,6 +828,20 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "open_id",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
|
||||
t.Fatalf("ImChatList.DryRun() = %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"sort_type":"ByCreateTimeAsc"`) {
|
||||
t.Fatalf("ImChatList.DryRun() missing sort_type: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
|
||||
@@ -772,3 +856,26 @@ func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
|
||||
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAllNonMemberPreSkip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
searchTypes string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"only public_not_joined", "public_not_joined", SkipReasonAllNonMember},
|
||||
{"public_not_joined with whitespace", " public_not_joined ", SkipReasonAllNonMember},
|
||||
{"private only", "private", ""},
|
||||
{"mixed includes public_not_joined", "public_not_joined,private", ""},
|
||||
{"all four types", "private,public_joined,external,public_not_joined", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := detectAllNonMemberPreSkip(c.searchTypes)
|
||||
if got != c.want {
|
||||
t.Fatalf("detectAllNonMemberPreSkip(%q) = %q, want %q", c.searchTypes, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,6 +859,7 @@ func TestShortcuts(t *testing.T) {
|
||||
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ImChatCreate is the +chat-create shortcut: creates a group chat or topic
|
||||
// chat via POST /open-apis/im/v1/chats. Supports user and bot identities;
|
||||
// --chat-mode selects group (default) or topic; --type selects private
|
||||
// (default) or public; --users/--bots invite members at creation.
|
||||
var ImChatCreate = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-create",
|
||||
Description: "Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager",
|
||||
Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager",
|
||||
Risk: "write",
|
||||
UserScopes: []string{"im:chat:create_by_user"},
|
||||
BotScopes: []string{"im:chat:create"},
|
||||
@@ -32,6 +36,7 @@ var ImChatCreate = common.Shortcut{
|
||||
{Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"},
|
||||
{Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"},
|
||||
{Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}},
|
||||
{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
|
||||
{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -141,9 +146,18 @@ var ImChatCreate = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// buildCreateChatBody assembles the POST /open-apis/im/v1/chats request
|
||||
// body. chat_mode is always emitted; an empty value (which can slip past
|
||||
// validateEnumFlags, since that helper skips empty strings) is pinned to
|
||||
// "group" so the wire never carries an unspecified chat_mode value.
|
||||
func buildCreateChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
chatMode := runtime.Str("chat-mode")
|
||||
if chatMode == "" {
|
||||
chatMode = "group"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"chat_type": runtime.Str("type"),
|
||||
"chat_mode": chatMode,
|
||||
}
|
||||
if name := runtime.Str("name"); name != "" {
|
||||
body["name"] = name
|
||||
|
||||
156
shortcuts/im/im_chat_list.go
Normal file
156
shortcuts/im/im_chat_list.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
|
||||
const imChatListPath = "/open-apis/im/v1/chats"
|
||||
|
||||
// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
|
||||
// list groups the current user/bot is a member of. Supports sort order,
|
||||
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
|
||||
var ImChatList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
},
|
||||
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(imChatListPath).
|
||||
Params(buildChatListParams(runtime))
|
||||
},
|
||||
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 100")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// Execute fetches one page of chats, optionally applies --exclude-muted
|
||||
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
|
||||
// populated only when --exclude-muted is set (backward compatible).
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params := buildChatListParams(runtime)
|
||||
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, _ := resData["items"].([]interface{})
|
||||
hasMore, pageToken := common.PaginationMeta(resData)
|
||||
|
||||
var items []map[string]interface{}
|
||||
for _, raw := range rawItems {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
|
||||
ExcludeMuted: runtime.Bool("exclude-muted"),
|
||||
IsBot: runtime.IsBot(),
|
||||
Chats: items,
|
||||
ChatIDKey: "chat_id",
|
||||
HasMore: hasMore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = mfOut.Chats
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chats": items,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No chats found.")
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, m := range items {
|
||||
row := map[string]interface{}{
|
||||
"chat_id": m["chat_id"],
|
||||
"name": m["name"],
|
||||
}
|
||||
if desc, _ := m["description"].(string); desc != "" {
|
||||
row["description"] = desc
|
||||
}
|
||||
if ownerID, _ := m["owner_id"].(string); ownerID != "" {
|
||||
row["owner_id"] = ownerID
|
||||
}
|
||||
if external, ok := m["external"].(bool); ok {
|
||||
row["external"] = external
|
||||
}
|
||||
if status, _ := m["chat_status"].(string); status != "" {
|
||||
row["chat_status"] = status
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
|
||||
if hasMore {
|
||||
fmt.Fprint(w, " (more available, use --page-token to fetch next page")
|
||||
if pageToken != "" {
|
||||
fmt.Fprintf(w, ", page_token: %s", pageToken)
|
||||
}
|
||||
fmt.Fprint(w, ")")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildChatListParams builds the query parameters for the GET /im/v1/chats
|
||||
// call from the runtime flag values. user_id_type and sort_type are always
|
||||
// present (their flag defaults are non-empty); page_token is omitted when
|
||||
// empty; page_size falls back to the API default of 20 when not provided.
|
||||
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"sort_type": runtime.Str("sort-type"),
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
params["page_size"] = n
|
||||
} else {
|
||||
params["page_size"] = 20
|
||||
}
|
||||
if pt := runtime.Str("page-token"); pt != "" {
|
||||
params["page_token"] = pt
|
||||
}
|
||||
return params
|
||||
}
|
||||
128
shortcuts/im/im_chat_list_test.go
Normal file
128
shortcuts/im/im_chat_list_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext —
|
||||
// it registers page-size as Int (the existing newTestRuntimeContext registers
|
||||
// it as String, which would short-circuit our buildChatListParams logic).
|
||||
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, val := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestBuildChatListParams_Defaults(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "open_id",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
}, nil)
|
||||
got := buildChatListParams(rt)
|
||||
if got["user_id_type"] != "open_id" {
|
||||
t.Fatalf("user_id_type = %v", got["user_id_type"])
|
||||
}
|
||||
if got["sort_type"] != "ByCreateTimeAsc" {
|
||||
t.Fatalf("sort_type = %v", got["sort_type"])
|
||||
}
|
||||
if got["page_size"] != 20 {
|
||||
t.Fatalf("page_size = %v, want 20", got["page_size"])
|
||||
}
|
||||
if _, present := got["page_token"]; present {
|
||||
t.Fatalf("page_token should be omitted when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChatListParams_Overrides(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "user_id",
|
||||
"sort-type": "ByActiveTimeDesc",
|
||||
"page-size": "50",
|
||||
"page-token": "tok_xyz",
|
||||
}, nil)
|
||||
got := buildChatListParams(rt)
|
||||
if got["user_id_type"] != "user_id" {
|
||||
t.Fatalf("user_id_type = %v", got["user_id_type"])
|
||||
}
|
||||
if got["sort_type"] != "ByActiveTimeDesc" {
|
||||
t.Fatalf("sort_type = %v", got["sort_type"])
|
||||
}
|
||||
if got["page_size"] != 50 {
|
||||
t.Fatalf("page_size = %v, want 50", got["page_size"])
|
||||
}
|
||||
if got["page_token"] != "tok_xyz" {
|
||||
t.Fatalf("page_token = %v", got["page_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestImChatList_Validate_PageSizeBounds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pageSize string
|
||||
wantErr bool
|
||||
}{
|
||||
{"zero rejected", "0", true},
|
||||
{"negative rejected", "-1", true},
|
||||
{"one ok", "1", false},
|
||||
{"hundred ok", "100", false},
|
||||
{"oneoone rejected", "101", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{"page-size": c.pageSize}, nil)
|
||||
err := ImChatList.Validate(context.Background(), rt)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("Validate() err = %v, wantErr=%v", err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "open_id",
|
||||
"sort-type": "ByActiveTimeDesc",
|
||||
"page-size": "30",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
|
||||
t.Fatalf("DryRun missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"sort_type":"ByActiveTimeDesc"`) {
|
||||
t.Fatalf("DryRun missing sort_type: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"page_size":30`) {
|
||||
t.Fatalf("DryRun missing page_size: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,14 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search
|
||||
// to find visible group chats by keyword and/or member open_ids. Supports
|
||||
// member/type filters, sort order, pagination, and (user identity only) the
|
||||
// --exclude-muted client-side mute filter.
|
||||
var ImChatSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-search",
|
||||
Description: "Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -32,7 +36,9 @@ var ImChatSearch = common.Shortcut{
|
||||
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
},
|
||||
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildSearchChatBody(runtime)
|
||||
params := buildSearchChatParams(runtime)
|
||||
@@ -41,6 +47,8 @@ var ImChatSearch = common.Shortcut{
|
||||
Params(params).
|
||||
Body(body)
|
||||
},
|
||||
// Validate enforces query/member-ids presence, --query rune cap, search-types
|
||||
// enum, --member-ids count and format, and --page-size bounds.
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
query := runtime.Str("query")
|
||||
memberIDs := runtime.Str("member-ids")
|
||||
@@ -79,6 +87,10 @@ var ImChatSearch = common.Shortcut{
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// Execute fetches one page, extracts per-item meta_data, optionally applies
|
||||
// the --exclude-muted client-side filter (with a PreSkipReason when
|
||||
// --search-types is exactly public_not_joined), and renders the result.
|
||||
// outData["filter"] is populated only when --exclude-muted is set.
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildSearchChatBody(runtime)
|
||||
params := buildSearchChatParams(runtime)
|
||||
@@ -106,16 +118,39 @@ var ImChatSearch = common.Shortcut{
|
||||
items = append(items, meta)
|
||||
}
|
||||
|
||||
preSkipReason := ""
|
||||
if runtime.Bool("exclude-muted") {
|
||||
preSkipReason = detectAllNonMemberPreSkip(runtime.Str("search-types"))
|
||||
}
|
||||
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
|
||||
ExcludeMuted: runtime.Bool("exclude-muted"),
|
||||
IsBot: runtime.IsBot(),
|
||||
PreSkipReason: preSkipReason,
|
||||
Chats: items,
|
||||
ChatIDKey: "chat_id",
|
||||
HasMore: hasMore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = mfOut.Chats
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chats": items,
|
||||
"total": int(total),
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No matching group chats found.")
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
@@ -154,11 +189,19 @@ var ImChatSearch = common.Shortcut{
|
||||
moreHint += ")"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d chat(s) found%s\n", int(total), moreHint)
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
|
||||
// is empty.
|
||||
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -194,6 +237,9 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
return body
|
||||
}
|
||||
|
||||
// buildSearchChatParams builds the query parameters for the POST
|
||||
// /im/v2/chats/search call. page_size defaults to the API default of 20 when
|
||||
// not provided; page_token is omitted when empty.
|
||||
func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
@@ -207,10 +253,11 @@ func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{
|
||||
return params
|
||||
}
|
||||
|
||||
// normalizeChatSearchQuery wraps hyphenated search queries in double quotes
|
||||
// because the search API treats hyphenated keywords specially and expects the
|
||||
// whole query to be quoted. Already-quoted input is unwrapped before requoting
|
||||
// so we don't emit nested quotes. Inputs without "-" pass through unchanged.
|
||||
func normalizeChatSearchQuery(query string) string {
|
||||
// The search API treats hyphenated keywords specially and expects the whole
|
||||
// query to be quoted. Normalize already-quoted input before requoting so we
|
||||
// don't emit nested quotes.
|
||||
if !strings.Contains(query, "-") {
|
||||
return query
|
||||
}
|
||||
@@ -219,3 +266,15 @@ func normalizeChatSearchQuery(query string) string {
|
||||
}
|
||||
return strconv.Quote(query)
|
||||
}
|
||||
|
||||
// detectAllNonMemberPreSkip returns SkipReasonAllNonMember when --search-types
|
||||
// is exactly "public_not_joined" — the one combination guaranteeing no member
|
||||
// chats, making the mute filter a no-op. Any other value (including empty or
|
||||
// mixed) returns "".
|
||||
func detectAllNonMemberPreSkip(searchTypesCSV string) string {
|
||||
types := common.SplitCSV(searchTypesCSV)
|
||||
if len(types) == 1 && types[0] == "public_not_joined" {
|
||||
return SkipReasonAllNonMember
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
320
shortcuts/im/mute_filter.go
Normal file
320
shortcuts/im/mute_filter.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package-level helper: client-side filter that drops muted chats from
|
||||
// search/list results by calling /open-apis/im/v1/chat_user_setting/batch_get_mute_status.
|
||||
//
|
||||
// The native chat search/list APIs do not return mute status; we fetch it as
|
||||
// a separate batch lookup, then drop is_muted=true items. Non-member /
|
||||
// invalid-format chat_ids come back via invalid_id_list and are silently
|
||||
// retained (we don't know their mute state). Bot identity is unsupported by
|
||||
// the upstream API (UAT-only), so we skip the filter and emit a machine-readable
|
||||
// skipped indicator instead of erroring.
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MuteFilterMeta describes the outcome of a single page's mute filter run.
|
||||
// UnknownCount is internal — used to compose the hint, not exposed in JSON.
|
||||
type MuteFilterMeta struct {
|
||||
Applied string
|
||||
Skipped bool
|
||||
SkipReason string
|
||||
FetchedCount int
|
||||
ReturnedCount int
|
||||
FilteredCount int
|
||||
UnknownCount int
|
||||
Hint string
|
||||
}
|
||||
|
||||
// MaxMuteStatusBatchSize is the upstream cap for chat_ids per
|
||||
// batch_get_mute_status call (after dedupe).
|
||||
const MaxMuteStatusBatchSize = 100
|
||||
|
||||
// BatchGetMuteStatusPath is the upstream HTTP path.
|
||||
const BatchGetMuteStatusPath = "/open-apis/im/v1/chat_user_setting/batch_get_mute_status"
|
||||
|
||||
// SkipReason constants — written to filter.skip_reason when Skipped=true.
|
||||
const (
|
||||
SkipReasonBotIdentity = "bot_identity_no_mute_data"
|
||||
SkipReasonAllNonMember = "all_non_member_search_types"
|
||||
)
|
||||
|
||||
// BuildMuteFilterHint composes the user/AI-facing English hint for a finished
|
||||
// filter run. hasMore is the underlying API's has_more (so we can suggest paging).
|
||||
// Returns "" when the filter ran but had no effect (FilteredCount==0 and not skipped).
|
||||
func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string {
|
||||
if meta.Skipped {
|
||||
switch meta.SkipReason {
|
||||
case SkipReasonBotIdentity:
|
||||
return "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
|
||||
case SkipReasonAllNonMember:
|
||||
if hasMore {
|
||||
return "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
|
||||
}
|
||||
return "All results on this page are non-member public groups; mute filter does not apply. No more pages."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if meta.FilteredCount == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
tail := "no more pages."
|
||||
if hasMore {
|
||||
tail = "use --page-token to fetch more."
|
||||
}
|
||||
|
||||
if meta.UnknownCount > 0 {
|
||||
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining, including %d non-member public group(s)); %s",
|
||||
meta.FilteredCount, meta.ReturnedCount, meta.UnknownCount, tail)
|
||||
}
|
||||
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining); %s",
|
||||
meta.FilteredCount, meta.ReturnedCount, tail)
|
||||
}
|
||||
|
||||
// BuildBatchGetMuteStatusBody constructs the request body for
|
||||
// POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status.
|
||||
func BuildBatchGetMuteStatusBody(chatIDs []string) map[string]interface{} {
|
||||
return map[string]interface{}{"chat_ids": chatIDs}
|
||||
}
|
||||
|
||||
// ParseBatchGetMuteStatusResponse maps the API response to:
|
||||
// - muted: chat_id -> is_muted, only for ids returned in items
|
||||
// - unknown: chat_ids that came back in invalid_id_list (any msg) OR
|
||||
// were in input but missing from both lists.
|
||||
//
|
||||
// unknown preserves input order for stable hint output.
|
||||
func ParseBatchGetMuteStatusResponse(input []string, resp map[string]interface{}) (map[string]bool, []string) {
|
||||
muted := make(map[string]bool, len(input))
|
||||
if rawItems, ok := resp["items"].([]interface{}); ok {
|
||||
for _, raw := range rawItems {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
cid, _ := item["chat_id"].(string)
|
||||
if cid == "" {
|
||||
continue
|
||||
}
|
||||
isMuted, _ := item["is_muted"].(bool)
|
||||
muted[cid] = isMuted
|
||||
}
|
||||
}
|
||||
|
||||
unknownSet := make(map[string]struct{})
|
||||
if rawInvalid, ok := resp["invalid_id_list"].([]interface{}); ok {
|
||||
for _, raw := range rawInvalid {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if id != "" {
|
||||
unknownSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range input {
|
||||
if _, hasMute := muted[id]; hasMute {
|
||||
continue
|
||||
}
|
||||
unknownSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
unknown := make([]string, 0, len(unknownSet))
|
||||
for _, id := range input {
|
||||
if _, ok := unknownSet[id]; ok {
|
||||
unknown = append(unknown, id)
|
||||
delete(unknownSet, id) // dedupe while preserving input order
|
||||
}
|
||||
}
|
||||
return muted, unknown
|
||||
}
|
||||
|
||||
// ApplyMuteFilter drops chats whose mute map entry is true. Chats whose id
|
||||
// is in the unknown set, or which have no chatIDKey value, are retained
|
||||
// (we have no basis to filter them) and counted as UnknownCount.
|
||||
//
|
||||
// Pure function; no API calls. The caller is responsible for fetching the
|
||||
// mute map via FetchMuteStatus.
|
||||
//
|
||||
// Invariant: meta.FetchedCount == meta.ReturnedCount + meta.FilteredCount.
|
||||
func ApplyMuteFilter(
|
||||
chats []map[string]interface{},
|
||||
chatIDKey string,
|
||||
muted map[string]bool,
|
||||
unknown []string,
|
||||
) ([]map[string]interface{}, MuteFilterMeta) {
|
||||
unknownSet := make(map[string]struct{}, len(unknown))
|
||||
for _, id := range unknown {
|
||||
unknownSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]map[string]interface{}, 0, len(chats))
|
||||
meta := MuteFilterMeta{Applied: "exclude_muted", FetchedCount: len(chats)}
|
||||
|
||||
for _, row := range chats {
|
||||
cid, _ := row[chatIDKey].(string)
|
||||
if cid == "" {
|
||||
out = append(out, row)
|
||||
meta.UnknownCount++
|
||||
continue
|
||||
}
|
||||
if _, isUnknown := unknownSet[cid]; isUnknown {
|
||||
out = append(out, row)
|
||||
meta.UnknownCount++
|
||||
continue
|
||||
}
|
||||
if isMuted, ok := muted[cid]; ok {
|
||||
if isMuted {
|
||||
meta.FilteredCount++
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
continue
|
||||
}
|
||||
// Defensive: id not in muted, not in unknown — treat as unknown, retain.
|
||||
out = append(out, row)
|
||||
meta.UnknownCount++
|
||||
}
|
||||
meta.ReturnedCount = len(out)
|
||||
return out, meta
|
||||
}
|
||||
|
||||
// ExtractChatIDs collects unique chat_ids (in input order) from a page of rows.
|
||||
// Rows missing the key or with an empty value are skipped.
|
||||
func ExtractChatIDs(chats []map[string]interface{}, chatIDKey string) []string {
|
||||
if len(chats) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(chats))
|
||||
out := make([]string, 0, len(chats))
|
||||
for _, row := range chats {
|
||||
cid, _ := row[chatIDKey].(string)
|
||||
if cid == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[cid]; dup {
|
||||
continue
|
||||
}
|
||||
seen[cid] = struct{}{}
|
||||
out = append(out, cid)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MuteFilterMetaToMap renders the meta as the "filter" sub-object the
|
||||
// command writes into outData. The schema is fixed-shape: exactly 5 fields,
|
||||
// regardless of skip state.
|
||||
//
|
||||
// Skip context (bot identity / all-non-member search-types) is encoded
|
||||
// entirely in the Hint string — consumers read the natural-language hint
|
||||
// to understand why the filter did or did not apply. UnknownCount and the
|
||||
// Skipped / SkipReason struct fields are internal-only (used to compose
|
||||
// Hint) and are not exposed in JSON.
|
||||
func MuteFilterMetaToMap(meta MuteFilterMeta) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"applied": meta.Applied,
|
||||
"fetched_count": meta.FetchedCount,
|
||||
"returned_count": meta.ReturnedCount,
|
||||
"filtered_count": meta.FilteredCount,
|
||||
"hint": meta.Hint,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchMuteStatus calls batch_get_mute_status for the given chat_ids and
|
||||
// parses the result. Caller MUST ensure len(chatIDs) <= MaxMuteStatusBatchSize
|
||||
// (the shortcuts already cap --page-size at 100, so a single page is safe).
|
||||
//
|
||||
// Empty input is a no-op (avoids triggering the upstream "chat_ids is empty"
|
||||
// InvalidParam).
|
||||
func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[string]bool, []string, error) {
|
||||
if len(chatIDs) == 0 {
|
||||
return map[string]bool{}, nil, nil
|
||||
}
|
||||
if len(chatIDs) > MaxMuteStatusBatchSize {
|
||||
return nil, nil, output.ErrValidation(
|
||||
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
|
||||
MaxMuteStatusBatchSize, len(chatIDs))
|
||||
}
|
||||
body := BuildBatchGetMuteStatusBody(chatIDs)
|
||||
resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("fetch mute status: %w", err)
|
||||
}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
|
||||
return muted, unknown, nil
|
||||
}
|
||||
|
||||
// MuteFilterInput captures everything the orchestrator needs from the calling shortcut.
|
||||
type MuteFilterInput struct {
|
||||
ExcludeMuted bool // value of --exclude-muted
|
||||
IsBot bool // current identity
|
||||
PreSkipReason string // optional caller-supplied skip reason (e.g. SkipReasonAllNonMember); leave empty under bot — IsBot is handled separately
|
||||
Chats []map[string]interface{} // page of result rows
|
||||
ChatIDKey string // key in row holding the chat_id ("chat_id" for both v1 list and v2 search meta_data)
|
||||
HasMore bool // for hint composition
|
||||
}
|
||||
|
||||
// MuteFilterOutput is what the shortcut writes back into outData.
|
||||
type MuteFilterOutput struct {
|
||||
Chats []map[string]interface{} // filtered (or unchanged when not applied)
|
||||
Meta MuteFilterMeta // zero-valued when ExcludeMuted=false; callers detect via Meta.Applied != ""
|
||||
}
|
||||
|
||||
// MaybeApplyMuteFilter is the single entry point shortcuts call.
|
||||
//
|
||||
// Behavior:
|
||||
// - ExcludeMuted=false: returns chats unchanged, Meta is zero-valued (Applied=="")
|
||||
// - ExcludeMuted=true && IsBot: skip the API call, mark Skipped with SkipReasonBotIdentity
|
||||
// - ExcludeMuted=true && PreSkipReason!="" (not bot): skip the API call, mark Skipped with that reason
|
||||
// - ExcludeMuted=true && len(chats)==0: skip the API call (avoids upstream
|
||||
// InvalidParam on empty chat_ids); meta has zero counts, Skipped=false
|
||||
// - ExcludeMuted=true && otherwise: fetch + apply; populate counts and Hint
|
||||
//
|
||||
// Callers detect whether the filter ran via out.Meta.Applied != "".
|
||||
// Callers compose the JSON map via MuteFilterMetaToMap(out.Meta) at the use site.
|
||||
func MaybeApplyMuteFilter(runtime *common.RuntimeContext, in MuteFilterInput) (MuteFilterOutput, error) {
|
||||
if !in.ExcludeMuted {
|
||||
return MuteFilterOutput{Chats: in.Chats}, nil
|
||||
}
|
||||
|
||||
meta := MuteFilterMeta{
|
||||
Applied: "exclude_muted",
|
||||
FetchedCount: len(in.Chats),
|
||||
ReturnedCount: len(in.Chats),
|
||||
}
|
||||
|
||||
switch {
|
||||
case in.IsBot:
|
||||
meta.Skipped = true
|
||||
meta.SkipReason = SkipReasonBotIdentity
|
||||
case in.PreSkipReason != "":
|
||||
meta.Skipped = true
|
||||
meta.SkipReason = in.PreSkipReason
|
||||
case len(in.Chats) == 0:
|
||||
// counts already zero; Skipped stays false
|
||||
default:
|
||||
ids := ExtractChatIDs(in.Chats, in.ChatIDKey)
|
||||
muted, unknown, err := FetchMuteStatus(runtime, ids)
|
||||
if err != nil {
|
||||
return MuteFilterOutput{}, err
|
||||
}
|
||||
var filtered []map[string]interface{}
|
||||
filtered, meta = ApplyMuteFilter(in.Chats, in.ChatIDKey, muted, unknown)
|
||||
in.Chats = filtered
|
||||
}
|
||||
|
||||
meta.Hint = BuildMuteFilterHint(meta, in.HasMore)
|
||||
return MuteFilterOutput{
|
||||
Chats: in.Chats,
|
||||
Meta: meta,
|
||||
}, nil
|
||||
}
|
||||
445
shortcuts/im/mute_filter_test.go
Normal file
445
shortcuts/im/mute_filter_test.go
Normal file
@@ -0,0 +1,445 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBuildMuteFilterHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
meta MuteFilterMeta
|
||||
hasMore bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "1 skipped bot identity",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity},
|
||||
hasMore: false,
|
||||
want: "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter.",
|
||||
},
|
||||
{
|
||||
name: "2 skipped all non-member, has_more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
|
||||
hasMore: true,
|
||||
want: "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more.",
|
||||
},
|
||||
{
|
||||
name: "3 skipped all non-member, no more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
|
||||
hasMore: false,
|
||||
want: "All results on this page are non-member public groups; mute filter does not apply. No more pages.",
|
||||
},
|
||||
{
|
||||
name: "4 filtered>0 unknown=0 has_more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
|
||||
hasMore: true,
|
||||
want: "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more.",
|
||||
},
|
||||
{
|
||||
name: "5 filtered>0 unknown=0 no more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
|
||||
hasMore: false,
|
||||
want: "Filtered out 3 muted chat(s) on this page (17 remaining); no more pages.",
|
||||
},
|
||||
{
|
||||
name: "6 filtered>0 unknown>0 has_more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
|
||||
hasMore: true,
|
||||
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more.",
|
||||
},
|
||||
{
|
||||
name: "7 filtered>0 unknown>0 no more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
|
||||
hasMore: false,
|
||||
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); no more pages.",
|
||||
},
|
||||
{
|
||||
name: "8 filtered=0 returns empty regardless of unknown/hasMore",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 5, ReturnedCount: 5, UnknownCount: 2},
|
||||
hasMore: true,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "9 skipped with unrecognized reason returns empty",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: "unknown_reason"},
|
||||
hasMore: false,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := BuildMuteFilterHint(c.meta, c.hasMore)
|
||||
if got != c.want {
|
||||
t.Fatalf("BuildMuteFilterHint() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchGetMuteStatusBody(t *testing.T) {
|
||||
got := BuildBatchGetMuteStatusBody([]string{"oc_a", "oc_b"})
|
||||
want := map[string]interface{}{"chat_ids": []string{"oc_a", "oc_b"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildBatchGetMuteStatusBody() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBatchGetMuteStatusResponse(t *testing.T) {
|
||||
t.Run("happy path with mixed muted/non-muted/invalid", func(t *testing.T) {
|
||||
input := []string{"oc_a", "oc_b", "oc_c", "bad"}
|
||||
resp := map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
|
||||
map[string]interface{}{"chat_id": "oc_b", "is_muted": false},
|
||||
},
|
||||
"invalid_id_list": []interface{}{
|
||||
map[string]interface{}{"id": "oc_c", "msg": "not_a_member"},
|
||||
map[string]interface{}{"id": "bad", "msg": "invalid_format"},
|
||||
},
|
||||
}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
|
||||
wantMuted := map[string]bool{"oc_a": true, "oc_b": false}
|
||||
wantUnknown := []string{"oc_c", "bad"}
|
||||
if !reflect.DeepEqual(muted, wantMuted) {
|
||||
t.Fatalf("muted = %v, want %v", muted, wantMuted)
|
||||
}
|
||||
if !reflect.DeepEqual(unknown, wantUnknown) {
|
||||
t.Fatalf("unknown = %v, want %v", unknown, wantUnknown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing chat_ids fall through to unknown", func(t *testing.T) {
|
||||
input := []string{"oc_a", "oc_b"}
|
||||
resp := map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
|
||||
},
|
||||
}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
|
||||
if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) {
|
||||
t.Fatalf("muted = %v", muted)
|
||||
}
|
||||
if !reflect.DeepEqual(unknown, []string{"oc_b"}) {
|
||||
t.Fatalf("unknown = %v", unknown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty response yields all unknown", func(t *testing.T) {
|
||||
input := []string{"oc_a"}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(input, map[string]interface{}{})
|
||||
if len(muted) != 0 {
|
||||
t.Fatalf("muted = %v, want empty", muted)
|
||||
}
|
||||
if !reflect.DeepEqual(unknown, []string{"oc_a"}) {
|
||||
t.Fatalf("unknown = %v", unknown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips nil entries and empty chat_id in items/invalid_id_list", func(t *testing.T) {
|
||||
input := []string{"oc_a", "oc_b"}
|
||||
resp := map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{"chat_id": "", "is_muted": false},
|
||||
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
|
||||
},
|
||||
"invalid_id_list": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{"id": "oc_b", "msg": "not_a_member"},
|
||||
},
|
||||
}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
|
||||
if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) {
|
||||
t.Fatalf("muted = %v", muted)
|
||||
}
|
||||
if !reflect.DeepEqual(unknown, []string{"oc_b"}) {
|
||||
t.Fatalf("unknown = %v", unknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyMuteFilter(t *testing.T) {
|
||||
chats := []map[string]interface{}{
|
||||
{"chat_id": "oc_a", "name": "alpha"},
|
||||
{"chat_id": "oc_b", "name": "beta"},
|
||||
{"chat_id": "oc_c", "name": "gamma"},
|
||||
{"chat_id": "oc_d", "name": "delta"},
|
||||
}
|
||||
|
||||
t.Run("drops only is_muted=true", func(t *testing.T) {
|
||||
muted := map[string]bool{"oc_a": true, "oc_b": false, "oc_c": true, "oc_d": false}
|
||||
got, meta := ApplyMuteFilter(chats, "chat_id", muted, nil)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0]["chat_id"] != "oc_b" || got[1]["chat_id"] != "oc_d" {
|
||||
t.Fatalf("got = %v, want [oc_b, oc_d]", got)
|
||||
}
|
||||
want := MuteFilterMeta{
|
||||
Applied: "exclude_muted", FetchedCount: 4, ReturnedCount: 2, FilteredCount: 2, UnknownCount: 0,
|
||||
}
|
||||
if meta != want {
|
||||
t.Fatalf("meta = %+v, want %+v", meta, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retains unknown chats and counts them", func(t *testing.T) {
|
||||
muted := map[string]bool{"oc_a": true, "oc_b": false}
|
||||
unknown := []string{"oc_c", "oc_d"}
|
||||
got, meta := ApplyMuteFilter(chats, "chat_id", muted, unknown)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(got) = %d, want 3 (oc_b + oc_c + oc_d)", len(got))
|
||||
}
|
||||
if meta.FilteredCount != 1 || meta.UnknownCount != 2 || meta.ReturnedCount != 3 {
|
||||
t.Fatalf("meta = %+v, want filtered=1 unknown=2 returned=3", meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves original order", func(t *testing.T) {
|
||||
muted := map[string]bool{"oc_b": true}
|
||||
got, _ := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"})
|
||||
gotIDs := []string{}
|
||||
for _, r := range got {
|
||||
gotIDs = append(gotIDs, r["chat_id"].(string))
|
||||
}
|
||||
want := []string{"oc_a", "oc_c", "oc_d"}
|
||||
if !reflect.DeepEqual(gotIDs, want) {
|
||||
t.Fatalf("ordering = %v, want %v", gotIDs, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing chatIDKey treated as unknown but kept", func(t *testing.T) {
|
||||
bad := []map[string]interface{}{{"name": "no_id"}}
|
||||
got, meta := ApplyMuteFilter(bad, "chat_id", map[string]bool{}, nil)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("missing-id row should be retained, got len = %d", len(got))
|
||||
}
|
||||
if meta.UnknownCount != 1 || meta.FilteredCount != 0 || meta.ReturnedCount != 1 {
|
||||
t.Fatalf("meta = %+v, want unknown=1 filtered=0 returned=1", meta)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invariant fetched == returned + filtered", func(t *testing.T) {
|
||||
muted := map[string]bool{"oc_a": true, "oc_b": false}
|
||||
_, meta := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"})
|
||||
if meta.FetchedCount != meta.ReturnedCount+meta.FilteredCount {
|
||||
t.Fatalf("invariant broken: fetched=%d, returned=%d, filtered=%d",
|
||||
meta.FetchedCount, meta.ReturnedCount, meta.FilteredCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractChatIDs(t *testing.T) {
|
||||
t.Run("dedupes and preserves order", func(t *testing.T) {
|
||||
chats := []map[string]interface{}{
|
||||
{"chat_id": "oc_a"},
|
||||
{"chat_id": "oc_b"},
|
||||
{"chat_id": "oc_a"},
|
||||
{"chat_id": ""},
|
||||
{"name": "no_id"},
|
||||
{"chat_id": "oc_c"},
|
||||
}
|
||||
got := ExtractChatIDs(chats, "chat_id")
|
||||
want := []string{"oc_a", "oc_b", "oc_c"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ExtractChatIDs() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty input yields empty slice", func(t *testing.T) {
|
||||
got := ExtractChatIDs(nil, "chat_id")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("ExtractChatIDs(nil) = %v, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMuteFilterMetaToMap(t *testing.T) {
|
||||
wantKeys := []string{"applied", "fetched_count", "returned_count", "filtered_count", "hint"}
|
||||
|
||||
t.Run("active filter exposes exactly 5 fields", func(t *testing.T) {
|
||||
meta := MuteFilterMeta{
|
||||
Applied: "exclude_muted",
|
||||
FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2,
|
||||
Hint: "test hint",
|
||||
}
|
||||
got := MuteFilterMetaToMap(meta)
|
||||
if got["applied"] != "exclude_muted" ||
|
||||
got["fetched_count"] != 20 || got["returned_count"] != 19 ||
|
||||
got["filtered_count"] != 1 || got["hint"] != "test hint" {
|
||||
t.Fatalf("MuteFilterMetaToMap() = %v", got)
|
||||
}
|
||||
assertExactKeys(t, got, wantKeys)
|
||||
})
|
||||
|
||||
t.Run("skipped path: hint carries the skip explanation, no extra fields", func(t *testing.T) {
|
||||
meta := MuteFilterMeta{
|
||||
Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity,
|
||||
FetchedCount: 5, ReturnedCount: 5, Hint: "skipped hint",
|
||||
}
|
||||
got := MuteFilterMetaToMap(meta)
|
||||
if got["hint"] != "skipped hint" {
|
||||
t.Fatalf("hint = %v, want \"skipped hint\"", got["hint"])
|
||||
}
|
||||
assertExactKeys(t, got, wantKeys)
|
||||
})
|
||||
}
|
||||
|
||||
// assertExactKeys fails the test if got has any keys outside want, or is missing any.
|
||||
func assertExactKeys(t *testing.T, got map[string]interface{}, want []string) {
|
||||
t.Helper()
|
||||
wantSet := make(map[string]struct{}, len(want))
|
||||
for _, k := range want {
|
||||
wantSet[k] = struct{}{}
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("missing required key %q", k)
|
||||
}
|
||||
}
|
||||
for k := range got {
|
||||
if _, ok := wantSet[k]; !ok {
|
||||
t.Errorf("unexpected key %q in MuteFilterMetaToMap output (got %v)", k, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeForOrchestrator builds a minimal RuntimeContext for testing the
|
||||
// branches of MaybeApplyMuteFilter that do NOT call the underlying API.
|
||||
func runtimeForOrchestrator(t *testing.T) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestMaybeApplyMuteFilter_NotEnabled(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
chats := []map[string]interface{}{{"chat_id": "oc_a"}}
|
||||
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
|
||||
ExcludeMuted: false,
|
||||
Chats: chats,
|
||||
ChatIDKey: "chat_id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(out.Chats) != 1 || out.Meta.Applied != "" {
|
||||
t.Fatalf("expected pass-through, got chats=%v meta.applied=%q", out.Chats, out.Meta.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeApplyMuteFilter_BotIdentity(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
chats := []map[string]interface{}{
|
||||
{"chat_id": "oc_a"},
|
||||
{"chat_id": "oc_b"},
|
||||
}
|
||||
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
|
||||
ExcludeMuted: true,
|
||||
IsBot: true,
|
||||
Chats: chats,
|
||||
ChatIDKey: "chat_id",
|
||||
HasMore: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(out.Chats) != 2 {
|
||||
t.Fatalf("bot skip should retain all chats, got %d", len(out.Chats))
|
||||
}
|
||||
if !out.Meta.Skipped {
|
||||
t.Fatalf("skipped should be true, got meta=%+v", out.Meta)
|
||||
}
|
||||
if out.Meta.SkipReason != SkipReasonBotIdentity {
|
||||
t.Fatalf("skip_reason = %v", out.Meta.SkipReason)
|
||||
}
|
||||
wantHint := "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
|
||||
if out.Meta.Hint != wantHint {
|
||||
t.Fatalf("hint = %q", out.Meta.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeApplyMuteFilter_PreSkipAllNonMember(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
chats := []map[string]interface{}{
|
||||
{"chat_id": "oc_a"},
|
||||
{"chat_id": "oc_b"},
|
||||
}
|
||||
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
|
||||
ExcludeMuted: true,
|
||||
IsBot: false,
|
||||
PreSkipReason: SkipReasonAllNonMember,
|
||||
Chats: chats,
|
||||
ChatIDKey: "chat_id",
|
||||
HasMore: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(out.Chats) != 2 {
|
||||
t.Fatalf("pre-skip should retain all chats, got %d", len(out.Chats))
|
||||
}
|
||||
if !out.Meta.Skipped || out.Meta.SkipReason != SkipReasonAllNonMember {
|
||||
t.Fatalf("meta = %+v", out.Meta)
|
||||
}
|
||||
wantHint := "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
|
||||
if out.Meta.Hint != wantHint {
|
||||
t.Fatalf("hint = %q", out.Meta.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeApplyMuteFilter_EmptyPage(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
|
||||
ExcludeMuted: true,
|
||||
Chats: nil,
|
||||
ChatIDKey: "chat_id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(out.Chats) != 0 {
|
||||
t.Fatalf("expected empty out, got %v", out.Chats)
|
||||
}
|
||||
if out.Meta.Applied != "exclude_muted" {
|
||||
t.Fatalf("meta.applied = %q, want exclude_muted", out.Meta.Applied)
|
||||
}
|
||||
if out.Meta.FetchedCount != 0 || out.Meta.ReturnedCount != 0 || out.Meta.FilteredCount != 0 {
|
||||
t.Fatalf("counts should all be zero, got meta=%+v", out.Meta)
|
||||
}
|
||||
if out.Meta.Skipped {
|
||||
t.Fatalf("empty page is not 'skipped', got meta.skipped=%v", out.Meta.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMuteStatus_OverLimit(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
ids := make([]string, MaxMuteStatusBatchSize+1)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("oc_%d", i)
|
||||
}
|
||||
_, _, err := FetchMuteStatus(rt, ids)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on over-limit batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMuteStatus_Empty(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
muted, unknown, err := FetchMuteStatus(rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(muted) != 0 || len(unknown) != 0 {
|
||||
t.Fatalf("expected empty results, got muted=%v unknown=%v", muted, unknown)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
|
||||
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -137,11 +137,11 @@ func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
} {
|
||||
if !strings.Contains(defaultHelp.String(), want) {
|
||||
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
|
||||
@@ -170,15 +170,22 @@ func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Document and content operations (v2).",
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
} {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
|
||||
@@ -255,24 +262,47 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
|
||||
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
wantTips := []string{
|
||||
"Tips:",
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
}
|
||||
unwantedTips := []string{
|
||||
"[NOTE]",
|
||||
"Use --api-version v2 for the latest API",
|
||||
"otherwise use the default v1 flags",
|
||||
"legacy v1 examples and flags",
|
||||
}
|
||||
if tt.apiVersion == "v2" {
|
||||
wantTips = []string{
|
||||
"Tips:",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
}
|
||||
unwantedTips = append(unwantedTips,
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
tt.shortcutHelp,
|
||||
tt.versionedFlag,
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"[NOTE]",
|
||||
"Use --api-version v2 for the latest API",
|
||||
} {
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range unwantedTips {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
|
||||
}
|
||||
@@ -385,7 +415,7 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
grouped[s.Service] = append(grouped[s.Service], entry{
|
||||
Verb: verb,
|
||||
Description: s.Description,
|
||||
Scopes: s.ScopesForIdentity("user"),
|
||||
Scopes: s.DeclaredScopesForIdentity("user"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
## 重要说明:画板编辑
|
||||
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
|
||||
### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
### 场景 1:已通过 docs +fetch --api-version v2 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch --api-version v2` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
1. 记录画板的 token
|
||||
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 场景 2:刚创建画板,需要编辑
|
||||
@@ -115,4 +115,4 @@ Drive Folder (云空间文件夹)
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
|
||||
@@ -101,7 +101,7 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
|
||||
@@ -35,6 +35,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
- `query` — 查询用户的任务列表
|
||||
- `add_sign` — 审批任务加签
|
||||
- `rollback` — 退回审批任务
|
||||
|
||||
## 权限表
|
||||
|
||||
@@ -49,4 +51,6 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
| `tasks.query` | `approval:task:read` |
|
||||
| `tasks.add_sign` | `approval:task:write` |
|
||||
| `tasks.rollback` | `approval:task:write` |
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(v2):创建和编辑飞书文档。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式,可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文)、更新文档(八种指令:str_replace/block_insert_after/block_copy_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、画板、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,7 +10,7 @@ metadata:
|
||||
|
||||
# docs (v2)
|
||||
|
||||
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
|
||||
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create --api-version v2`、`docs +fetch --api-version v2`、`docs +update --api-version v2` 命令必须携带 `--api-version v2`。**
|
||||
|
||||
```bash
|
||||
# 常用示例
|
||||
@@ -23,7 +23,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
|
||||
@@ -49,7 +49,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
|
||||
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
|
||||
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
|
||||
|
||||
**补充:** 云空间资源发现统一走 [`drive +search`](../lark-drive/references/lark-drive-search.md);当用户口头说"表格/报表/最近我编辑过的 xxx"时,也优先从 `drive +search` 开始。老的 `docs +search` 只在沿用 `--filter` JSON 的存量脚本里保留,后续会下线。
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
|
||||
## 嵌入电子表格 / 多维表格
|
||||
|
||||
返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
|
||||
返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch --api-version v2` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Markdown 格式参考
|
||||
|
||||
`docs +fetch / +create / +update` 使用 `--doc-format markdown` 时适用。
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
|
||||
|
||||
## 转义规则
|
||||
|
||||
@@ -34,14 +34,14 @@
|
||||
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
|
||||
|
||||
**导出已转义,不要反转义:**
|
||||
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
|
||||
`docs +fetch --api-version v2 --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
|
||||
|
||||
**写入时必须转义:**
|
||||
使用 `docs +create` 或 `docs +update` 的 `--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
|
||||
|
||||
**导出 → 更新 工作流示例:**
|
||||
|
||||
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
|
||||
1. `docs +fetch --api-version v2` 导出得到 `C:\\Users\\test\[1\]`
|
||||
2. 用 `str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
|
||||
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
> - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
|
||||
> - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。
|
||||
>
|
||||
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
|
||||
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --api-version v2 --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
|
||||
|
||||
## 参数
|
||||
|
||||
@@ -221,7 +221,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 画板处理
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入新画板 | 继续步骤 2 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3 |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
### 步骤 2:在文档中创建空白画板
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
### 第三波 — 整合审查 + 画板意图识别(串行)
|
||||
|
||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ Drive Folder (云空间文件夹)
|
||||
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch` 支持直接传入 URL |
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`slides` 仅支持 block_id,且都支持最终解析到对应类型的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
@@ -229,8 +229,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Duplicate remote `rel_path` conflicts fail by default before writing; for duplicate files only, `--on-duplicate-remote rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` explicitly choose one. Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
|
||||
| [`+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. |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
|
||||
@@ -238,7 +238,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Duplicate remote `rel_path` conflicts fail by default before upload / overwrite / delete; use `--on-duplicate-remote newest\|oldest` only when the conflict is duplicate files and you explicitly want to target one existing remote file. Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
|
||||
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `summary.downloaded` | 成功下载的文件数 |
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需下载”而跳过的文件数 |
|
||||
| `summary.failed` | 下载或写盘失败的文件数 |
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) |
|
||||
@@ -38,6 +38,10 @@
|
||||
# 基础用法 —— 把云端 fldcXXX 镜像到 ./repo
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 推荐的重复同步用法:smart 会按 modified_time 跳过已经对齐的本地文件
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists smart
|
||||
|
||||
# 已存在的本地文件保持不动
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists skip
|
||||
@@ -58,7 +62,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` |
|
||||
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(**默认**,Drive 作为权威源时使用)/ `smart`(**推荐用于重复增量同步**;当本地 mtime 已与远端 `modified_time` 匹配或更新时跳过下载)/ `skip` |
|
||||
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `rename` / `newest` / `oldest` |
|
||||
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"(**不删空目录**,因此是 file-level mirror);**必须配合 `--yes`** |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
@@ -67,7 +71,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
|
||||
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。
|
||||
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` / `smart` / `skip`。其中 **`smart` 是推荐的重复同步模式**:只要本地 mtime 在远端时间精度下已经等于或晚于远端 `modified_time`,就跳过下载;时间戳缺失/非法时会退回安全路径继续下载,不会盲跳。想做 `keep-both` 这类的仍需自己改名再 pull。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。
|
||||
|
||||
## --delete-local 的安全行为
|
||||
@@ -106,8 +110,8 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 下载流量 ≈ 云端待下载文件的总字节数。pull 是**全量**写盘 —— 跟 `+status` 不一样,不会跳过"内容相同"的文件(status 是按 hash 比较,pull 是按 `--if-exists`),所以一次跑可能很重。
|
||||
- 想避免重跑全量,可以先 `+status` 找出 `new_remote` 和 `modified`,再只对这些文件单独 `+download`。
|
||||
- 默认 `overwrite` 下,重复跑会重新下载所有命中的同名文件;`skip` 下则完全不碰已存在文件;**`smart` 下才会按 `modified_time` 跳过已经对齐的本地文件**,适合重复增量同步。
|
||||
- 想更精细地控制下载量,可以先 `+status` 找出 `new_remote` 和 `modified`,再只对这些文件单独 `+download`;或者直接在整目录同步时使用 `--if-exists smart`。
|
||||
- 大文件会用 SDK 的流式下载(不会把整个 body 读进内存),但本地磁盘空间需要够。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `summary.uploaded` | 成功新建或覆盖的文件数 |
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
@@ -36,10 +36,14 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础用法 —— 把本地 ./repo 增量推送到云端 fldcXXX
|
||||
# 基础用法 —— 把本地 ./repo 推送到云端 fldcXXX
|
||||
# 默认 --if-exists=skip:已经存在的远端文件保持不动,只新增、不覆盖。
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 重复同步时可用 smart 做增量优化:它会按 modified_time 跳过已对齐的远端文件;但如果远端更旧,仍会继续走覆盖路径
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists smart
|
||||
|
||||
# 显式覆盖远端同名文件(依赖 upload_all 的灰度协议字段,详见下文"覆盖语义")
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite
|
||||
@@ -62,7 +66,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") |
|
||||
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `smart`(用于重复增量同步;当远端 `modified_time` 已匹配或更新时跳过上传,否则继续走覆盖路径)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") |
|
||||
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `newest` / `oldest` |
|
||||
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
@@ -71,13 +75,15 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。
|
||||
- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token,不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。
|
||||
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。
|
||||
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` / `smart` / `skip`。其中 `smart` 是**增量优化模式**:只要远端 `modified_time` 在同等时间精度下已经等于或晚于本地 mtime,就跳过上传;时间戳缺失/非法时会退回安全路径继续上传,不会盲跳。**但如果远端更旧,`smart` 会继续走和 `overwrite` 相同的覆盖路径,因此也继承同样的 rollout / version 返回 caveat。** 想做 `keep-both` 这类的仍需自行改名再 push。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
`--if-exists=overwrite` 走 `POST /open-apis/drive/v1/files/upload_all`,并在 form 中带上现有文件的 `file_token`,由后端原地更新内容并返回新版本号。`items[].version` 字段会回填该版本号。
|
||||
|
||||
`--if-exists=smart` 是给“重复跑同步”的场景增加的增量优化:当远端 `modified_time` 在同等时间精度下已经等于或晚于本地 mtime 时,命令会把该文件计为 `skipped`;时间戳缺失、非法或更旧时,则继续走正常上传/覆盖路径。**也就是说,只要 smart 判定“远端不够新”,它就会进入与 `--if-exists=overwrite` 相同的覆盖实现,因此在未 rollout version 字段的 tenant 上仍可能非零失败。**
|
||||
|
||||
> **为什么默认是 `skip` 而不是 `overwrite`:** `upload_all` 接受 `file_token` 字段、并在响应里返回 `version` 是设计文档(Drive 同步盘)规定的协议;此后端尚在灰度发布。在还未开通该字段的 tenant 上,`--if-exists=overwrite` 会因"无 version 返回"而把对应文件标成 `failed`,整次 `+push` 也会因此非零退出。所以默认值故意定为 `skip`:第一次往一个已经有内容的目录里 push,不会因为协议没到位就把整次运行打挂;要真的覆盖远端,必须显式带 `--if-exists overwrite`。新建上传不依赖该字段,不受影响。
|
||||
|
||||
大文件(>20MB)会自动切到三段式 `upload_prepare` / `upload_part` / `upload_finish`;该路径下 `version` 暂未在响应中返回,覆盖结果中 `items[].version` 会留空,但 `file_token` 与 `action: overwritten` 仍会正确产出。
|
||||
@@ -122,8 +128,8 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 上传流量 ≈ 本地待上传文件的总字节数。push 是**全量**上传 —— 跟 `+status` 不一样,不会按 hash 跳过"内容相同"的文件(status 是按 hash 比较,push 是按 `--if-exists`),所以一次跑可能很重。
|
||||
- 想避免重跑全量,可以先 `+status` 找出 `new_local` 和 `modified`,再只对这些文件单独上传 / 覆盖。
|
||||
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
- 想更精细地控制传输量,可以先 `+status` 找出 `new_local` 和 `modified`,再只对这些文件单独上传 / 覆盖;或者直接在整目录同步时使用 `--if-exists smart`。
|
||||
- 大文件会用三段式分片上传(不会把整个 body 读进内存),但本地磁盘和上行带宽需要够。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
按 **精确 SHA-256**(默认)或 **快速 modified_time**(`--quick`)比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `new_local` | 仅本地存在 |
|
||||
| `new_remote` | 仅云端存在 |
|
||||
| `modified` | 双端都存在但 hash 不一致 |
|
||||
| `unchanged` | 双端都存在且 hash 一致 |
|
||||
| `modified` | 双端都存在且本次检测判定为已变更:`detection=exact` 时表示 hash 不一致;`detection=quick` 时表示本地 mtime 与远端 `modified_time` 不一致,或远端时间戳不可可信 |
|
||||
| `unchanged` | 双端都存在且本次检测判定为未变更:`detection=exact` 时表示 hash 一致;`detection=quick` 时表示本地 mtime 与远端 `modified_time` 相等 |
|
||||
|
||||
只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。
|
||||
只读命令:
|
||||
|
||||
- 默认 `detection=exact`:双端都有的文件会从云端拉一份字节流过来在内存里算 hash,不下载落盘,但大目录 / 大文件会有可观的网络流量。
|
||||
- 传 `--quick` 后 `detection=quick`:只比较本地 mtime 与远端 `modified_time`,**不下载远端文件内容**,适合先做快速预检查;它是 best-effort,不等同于严格内容一致性判断。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
@@ -26,7 +29,13 @@ lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 只看 hash 不一致的项(结合 --jq 过滤)
|
||||
# 快速模式 —— 只比较 modified_time,不下载远端文件内容
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx \
|
||||
--quick
|
||||
|
||||
# 只看判定为 modified 的项(exact=hash 不一致;quick=mtime 不一致)(结合 --jq 过滤)
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx \
|
||||
@@ -39,6 +48,7 @@ lark-cli drive +status \
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | Drive 文件夹 token |
|
||||
| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` |
|
||||
|
||||
## 输出 schema
|
||||
|
||||
@@ -46,6 +56,7 @@ lark-cli drive +status \
|
||||
|
||||
```json
|
||||
{
|
||||
"detection": "exact",
|
||||
"new_local": [{"rel_path": "..."}],
|
||||
"new_remote": [{"rel_path": "...", "file_token": "..."}],
|
||||
"modified": [{"rel_path": "...", "file_token": "..."}],
|
||||
@@ -53,6 +64,11 @@ lark-cli drive +status \
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `detection=exact`:默认模式,双端都有的文件会下载远端字节流并做 SHA-256 比较。
|
||||
- `detection=quick`:`--quick` 模式,只按本地 mtime 与远端 `modified_time` 做 best-effort 判断。
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||
|
||||
远端同名文件冲突时:
|
||||
@@ -84,6 +100,7 @@ lark-cli drive +status \
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
||||
- `--quick` 模式下,双端都有的文件只在 **远端时间精度** 下比较 `modified_time` / 本地 mtime:相等才记为 `unchanged`,否则记为 `modified`;远端时间戳缺失或非法时,走保守路径记为 `modified`,不会盲判 `unchanged`。
|
||||
|
||||
## 范围限制
|
||||
|
||||
@@ -99,9 +116,10 @@ lark-cli drive +status \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- 默认 `detection=exact` 下,`unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- `--quick` / `detection=quick` 下,不会下载双端共有文件的远端内容,执行时间更接近 `O(文件数量)`,而不是 `O(总文件大小)`。
|
||||
- 仅一侧存在的文件不会被下载。
|
||||
- Hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
- 默认模式的 hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
@@ -110,7 +128,7 @@ lark-cli drive +status \
|
||||
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
|
||||
| 下载并 hash 文件 | `drive:file:download` |
|
||||
|
||||
如果当前 token 缺这些 scope,命令会直接报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只声明上面这两个细粒度 scope。
|
||||
默认会先要求 `drive:drive.metadata:readonly`。在 `detection=exact` 路径(默认,不传 `--quick`)下,CLI 还会额外要求 `drive:file:download`;传 `--quick` 时不会要求下载 scope。如果当前 token 缺本次执行路径需要的 scope,命令会报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只依赖上面这些细粒度 scope。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ lark-cli drive +upload --file ./report.pdf
|
||||
# 自定义上传后的文件名
|
||||
lark-cli drive +upload --file ./report.pdf --name "季度总结.pdf"
|
||||
|
||||
# 覆盖已存在文件(原地覆盖,保留 file_token)
|
||||
lark-cli drive +upload --file ./report.pdf --file-token boxcn_existing_file
|
||||
|
||||
# 原生命令(高级/分片上传):预上传 + 完成上传
|
||||
lark-cli drive files upload_prepare --data '{
|
||||
"file_name": "report.pdf",
|
||||
"parent_type": "explorer",
|
||||
"parent_node": "fldbc_xxx",
|
||||
"size": 1048576
|
||||
"size": 1048576,
|
||||
"file_token": "boxcn_existing_file"
|
||||
}'
|
||||
lark-cli drive files upload_finish --data '{
|
||||
"upload_id": "<UPLOAD_ID>",
|
||||
@@ -40,7 +44,9 @@ lark-cli schema drive.files.upload_prepare
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果文件是**以应用身份(bot)上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
|
||||
> 如果文件是**以应用身份(bot)新建上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 如果这次调用传了 `--file-token`,表示是在**覆盖已有文件**,CLI **不会**额外修改该文件权限。
|
||||
>
|
||||
> 以应用身份上传时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该文件的可管理权限
|
||||
@@ -51,12 +57,18 @@ lark-cli schema drive.files.upload_prepare
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
> [!TIP]
|
||||
> 当底层上传接口返回版本号时,shortcut 会在结果里额外透出 `version`。
|
||||
|
||||
## 目标位置选择(关键)
|
||||
|
||||
- 上传到 Drive 文件夹:传 `--folder-token <folder_token>`,shortcut 会发送 `parent_type=explorer`
|
||||
- 上传到 wiki 节点:传 `--wiki-token <wiki_token>`,shortcut 会发送 `parent_type=wiki`
|
||||
- 上传到 Drive 根目录:`--folder-token` 和 `--wiki-token` 都不传
|
||||
- 覆盖已有文件:额外传 `--file-token <existing_file_token>`;shortcut 会把它原样透传到底层 `upload_all` / `upload_prepare`,让后端按覆盖语义写入
|
||||
- bot 模式下,`--file-token` 覆盖只改文件内容;不会额外给当前 CLI 用户补 `full_access`
|
||||
- 不要传空目标值:`--folder-token ""` / `--wiki-token ""` 会被视为参数错误;如需上传到 Drive 根目录,应直接省略这两个参数
|
||||
- 不要传空 `--file-token`:如需新建上传,直接省略该参数;显式传空字符串会报错
|
||||
- `--folder-token` 和 `--wiki-token` 互斥,不要同时传
|
||||
- `--wiki-token` 传的是 **wiki node token**,不是 `space_id`
|
||||
|
||||
@@ -65,6 +77,7 @@ Shortcut 参数:
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地文件路径 |
|
||||
| `--file-token` | 否 | 已存在文件的 token;传入后按“覆盖已有文件”语义上传 |
|
||||
| `--folder-token` | 否 | 目标文件夹 token;与 `--wiki-token` 互斥;省略时默认为 Drive 根目录;显式传空字符串会报错 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;会映射为 `parent_type=wiki`、`parent_node=<wiki_token>`;显式传空字符串会报错 |
|
||||
| `--name` | 否 | 上传后的文件名;默认使用本地文件名 |
|
||||
@@ -77,6 +90,7 @@ Shortcut 参数:
|
||||
| `parent_type` | 是 | 父节点类型;上传到文件夹 / 根目录时用 `"explorer"`,上传到 wiki 节点时用 `"wiki"` |
|
||||
| `parent_node` | 是 | 父节点 token;`explorer` 时传文件夹 token(根目录可为空字符串),`wiki` 时传 wiki node token |
|
||||
| `size` | 是 | 文件大小(字节) |
|
||||
| `file_token` | 否 | 已存在文件 token;传入后覆盖该文件内容 |
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -68,9 +68,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
@@ -96,7 +97,6 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
- `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
|
||||
- `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
|
||||
- `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
|
||||
- `list` — 获取用户或机器人所在的群列表。Identity: supports `user` and `bot`.
|
||||
- `update` — 更新群信息。Identity: supports `user` and `bot`.
|
||||
|
||||
### chat.members
|
||||
@@ -141,7 +141,6 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `chats.create` | `im:chat:create` |
|
||||
| `chats.get` | `im:chat:read` |
|
||||
| `chats.link` | `im:chat:read` |
|
||||
| `chats.list` | `im:chat:read` |
|
||||
| `chats.update` | `im:chat:update` |
|
||||
| `chat.members.bots` | `im:chat.members:read` |
|
||||
| `chat.members.create` | `im:chat.members:write_only` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, and chat type (private/public).
|
||||
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, chat type (private/public), and group mode. Set `--chat-mode topic` to create a topic chat.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `POST /open-apis/im/v1/chats`).
|
||||
|
||||
@@ -18,6 +18,9 @@ lark-cli im +chat-create --name "My Group"
|
||||
# Create a public group (name is required and must be at least 2 characters)
|
||||
lark-cli im +chat-create --name "Public Group" --type public
|
||||
|
||||
# Create a topic chat
|
||||
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
|
||||
|
||||
# Specify the group owner
|
||||
lark-cli im +chat-create --name "My Group" --owner ou_xxx
|
||||
|
||||
@@ -55,12 +58,15 @@ lark-cli im +chat-create --name "My Group" --dry-run
|
||||
| `--users <ids>` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids |
|
||||
| `--bots <ids>` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs |
|
||||
| `--owner <open_id>` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) |
|
||||
| `--type <type>` | No | `private` (default) or `public` | Group type |
|
||||
| `--type <type>` | No | `private` (default) or `public` | Group type. Default to `private`; pass `public` only when the user explicitly asks for a discoverable/public group. |
|
||||
| `--chat-mode <mode>` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`). When the user asks for a topic chat, pass `topic` explicitly — do not rely on the default. |
|
||||
| `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--as <identity>` | No | `bot` or `user` | Identity type |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> **`--chat-mode topic` vs "normal group with topic-message mode"**: `--chat-mode topic` here creates a 话题群 — the entire group is a topic chat. This is different from "normal group (`chat_mode=group`) + topic-message mode (`group_message_type=thread`)". This CLI exposes only `chat_mode`; `group_message_type` is intentionally not surfaced.
|
||||
|
||||
## AI Usage Guidance
|
||||
|
||||
### When using `--as bot`
|
||||
|
||||
113
skills/lark-im/references/lark-im-chat-list.md
Normal file
113
skills/lark-im/references/lark-im-chat-list.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# im +chat-list
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# List the user's chats (default sort: ByCreateTimeAsc)
|
||||
lark-cli im +chat-list
|
||||
|
||||
# Sort by recent activity (most recently active first)
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc
|
||||
|
||||
# Limit page size
|
||||
lark-cli im +chat-list --page-size 50
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-list --page-token "xxx"
|
||||
|
||||
# Drop muted chats (user identity only)
|
||||
lark-cli im +chat-list --exclude-muted
|
||||
|
||||
# JSON output
|
||||
lark-cli im +chat-list --format json
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +chat-list --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Limits | Description |
|
||||
|------|------|------|------|
|
||||
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
|
||||
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
|------|------|
|
||||
| `chat_id` | Chat ID (`oc_xxx` format) |
|
||||
| `name` | Chat name |
|
||||
| `description` | Chat description |
|
||||
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
|
||||
| `external` | Whether the chat is external |
|
||||
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
|
||||
|
||||
## Filtering muted chats
|
||||
|
||||
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the list call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
|
||||
|
||||
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
|
||||
|
||||
```json
|
||||
{
|
||||
"chats": [...],
|
||||
"filter": {
|
||||
"applied": "exclude_muted",
|
||||
"fetched_count": 20,
|
||||
"returned_count": 17,
|
||||
"filtered_count": 3,
|
||||
"hint": "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: List my recent chats
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --page-size 10
|
||||
```
|
||||
|
||||
### Scenario 2: List my non-muted chats sorted by activity
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --exclude-muted
|
||||
```
|
||||
|
||||
### Scenario 3: Iterate all my chats programmatically
|
||||
|
||||
```bash
|
||||
TOKEN=""
|
||||
while :; do
|
||||
RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json)
|
||||
echo "$RESP" | jq -r '.data.chats[].chat_id'
|
||||
HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more')
|
||||
[ "$HAS_MORE" = "true" ] || break
|
||||
TOKEN=$(echo "$RESP" | jq -r '.data.page_token')
|
||||
done
|
||||
```
|
||||
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
| Symptom | Root Cause | Solution |
|
||||
|---------|---------|---------|
|
||||
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
|
||||
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
|
||||
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
|
||||
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
|
||||
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |
|
||||
@@ -129,7 +129,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
lark-cli im +chat-search --query "<chat name keyword>" --format json
|
||||
lark-cli im +chat-messages-list --chat-id <chat_id>
|
||||
```
|
||||
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
|
||||
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
|
||||
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
|
||||
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
|
||||
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
|
||||
|
||||
@@ -49,6 +49,7 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `--sort-by <field>` | No | `create_time_desc`, `update_time_desc`, `member_count_desc` | Sort field in descending order |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
@@ -65,6 +66,27 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `external` | Whether the chat is external |
|
||||
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
|
||||
|
||||
## Filtering muted chats
|
||||
|
||||
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
|
||||
|
||||
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
|
||||
|
||||
```json
|
||||
{
|
||||
"chats": [...],
|
||||
"filter": {
|
||||
"applied": "exclude_muted",
|
||||
"fetched_count": 20,
|
||||
"returned_count": 19,
|
||||
"filtered_count": 1,
|
||||
"hint": "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: only confirmed-muted chats count toward `filtered_count`; non-member public groups are retained and surfaced in `hint`. For strict member-only results, combine with `--search-types "private,public_joined,external"`.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: Search chats that contain a keyword
|
||||
@@ -106,7 +128,7 @@ When the user asks to search chats, follow these rules:
|
||||
2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats.
|
||||
3. **Control result volume:** the result set may be large. Use `--page-size` deliberately.
|
||||
4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`).
|
||||
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `im chats list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
|
||||
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ lark-cli im +chat-search --query "<chat name keyword>" --format json
|
||||
lark-cli im +messages-search --query "keyword" --chat-id <chat_id>
|
||||
```
|
||||
|
||||
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
|
||||
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
|
||||
|
||||
## Work Summary / Report Generation
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-shared
|
||||
version: 1.0.0
|
||||
description: "飞书/Lark CLI 共享基础:应用配置初始化、认证登录(auth login)、身份切换(--as user/bot)、权限与 scope 管理、Permission denied 错误处理、安全规则。当用户需要第一次配置(`lark-cli config init`)、使用登录授权(`lark-cli auth login`)、遇到权限不足、切换 user/bot 身份、配置 scope、或首次使用 lark-cli 时触发。"
|
||||
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
@@ -82,11 +82,13 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(CLI 和 Skills 需要同时更新):
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
npm update -g @larksuite/cli && npx skills add larksuite/cli -g -y
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent**以加载最新 Skills
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
|
||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: lark-slides-creator
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片创作工作流:从自然语言需求创建、重构、美化完整 PPT,覆盖规划、模板选择、视觉风格、素材规划和创建后验证。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
# slides creator workflow
|
||||
|
||||
> 执行 XML/API 前必须读取 ../lark-slides/SKILL.md 和对应 reference。
|
||||
|
||||
This skill is the natural-language entry point for creating polished presentations. It owns planning, design, template, asset, and quality-validation workflows. It delegates all XML/API execution to [`../lark-slides/SKILL.md`](../lark-slides/SKILL.md).
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this skill when the user asks for:
|
||||
|
||||
- A new complete presentation from a topic, notes, outline, document, meeting, or rough prompt.
|
||||
- Beautification, restructuring, major rewrite, or formal-report polishing.
|
||||
- Template selection or a deck based on a theme, scene, industry, or visual style.
|
||||
- Visual direction, palette, typography, layout system, or executive-ready presentation quality.
|
||||
- Asset planning, image search/download/upload planning, or deciding where visuals belong.
|
||||
- Creation-time and post-creation validation for content completeness and visual quality.
|
||||
|
||||
For a narrow raw XML/API operation, use `lark-slides` directly.
|
||||
|
||||
## Required Execution Dependency
|
||||
|
||||
Before running any `lark-cli slides` command or writing final XML:
|
||||
|
||||
1. Read [`../lark-slides/SKILL.md`](../lark-slides/SKILL.md).
|
||||
2. Read [`../lark-slides/references/xml-schema-quick-ref.md`](../lark-slides/references/xml-schema-quick-ref.md).
|
||||
3. Read the relevant execution reference, such as `lark-slides-create.md`, `lark-slides-media-upload.md`, `lark-slides-replace-slide.md`, or an `xml_presentation.*` API reference.
|
||||
|
||||
Use the execution skill's lint tool from here when XML is available:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Understand the deck goal.
|
||||
Capture topic, audience, page count, source material, language, formality, delivery setting, and any brand/style constraints. If the user gives enough information, proceed with explicit assumptions instead of blocking on questions.
|
||||
|
||||
2. Choose template or custom direction.
|
||||
If the request mentions templates, style, theme, or a common deck scenario, search templates first:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
```
|
||||
|
||||
Offer 2-3 concise candidates when user choice matters. If one template is clearly best for a lightweight request, state the default and continue unless the user asked to choose.
|
||||
|
||||
3. Plan the deck.
|
||||
Build a page-by-page outline with title, role, key message, and intended layout for each slide. For formal reports, make the argument flow explicit: context, evidence, analysis, recommendation, next steps.
|
||||
|
||||
4. Design the visual system.
|
||||
Define palette, typography hierarchy, spacing, page rhythm, chart/table treatment, and recurring elements. Keep slides visual and low-density; do not produce document-like pages.
|
||||
|
||||
5. Plan assets.
|
||||
Decide which pages need screenshots, photos, diagrams, icons, or charts. External images must become local files first, then execution uses `+media-upload` or `@./path` placeholders as described in `lark-slides`.
|
||||
|
||||
6. Generate XML and execute through `lark-slides`.
|
||||
Use template summaries or extracted page slices when helpful, but rewrite all placeholder copy into the user's real content. For complex decks, prefer the two-step create flow from `lark-slides`.
|
||||
|
||||
7. Validate after creation.
|
||||
Read the created presentation XML with `xml_presentations get`, confirm page count and expected content, run lint when possible, then fix issues with `+replace-slide` or raw slide APIs.
|
||||
|
||||
## Template Workflow
|
||||
|
||||
Template assets live in this skill:
|
||||
|
||||
- [`references/template-catalog.md`](references/template-catalog.md)
|
||||
- [`references/template-index.json`](references/template-index.json)
|
||||
- [`assets/templates/`](assets/templates/)
|
||||
- [`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
|
||||
Machine-first commands:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py search --query "工作汇报" --tone light --limit 3
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py summarize --template office--work_report --label 内容
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py extract --template office--work_report --label 封面 --out /tmp/work-report-cover.xml
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Search using the user's original wording.
|
||||
- Show only 2-3 candidate templates unless the user asks for the full catalog.
|
||||
- Summarize a target page type before extracting XML.
|
||||
- Do not read entire template XML files by default.
|
||||
- Reuse theme, spacing, and structure; do not copy placeholder text.
|
||||
|
||||
## References
|
||||
|
||||
| Reference | Purpose |
|
||||
| --- | --- |
|
||||
| [planning-layer.md](references/planning-layer.md) | Deck planning and outline workflow. |
|
||||
| [visual-planning.md](references/visual-planning.md) | Visual style and layout design guidance. |
|
||||
| [asset-planning.md](references/asset-planning.md) | Asset selection, local-file, and upload planning. |
|
||||
| [template-catalog.md](references/template-catalog.md) | Template matching catalog. |
|
||||
| [slide-templates.md](references/slide-templates.md) | Copyable slide XML patterns for creation. |
|
||||
| [validation-checklist.md](references/validation-checklist.md) | Creation quality and post-create validation checklist. |
|
||||
@@ -1,21 +0,0 @@
|
||||
# Asset Planning
|
||||
|
||||
Use this when a deck needs screenshots, photos, diagrams, logos, icons, or chart data.
|
||||
|
||||
## Asset Plan
|
||||
|
||||
For each asset, record:
|
||||
|
||||
- Slide number and purpose.
|
||||
- Asset type: screenshot, product image, chart, diagram, logo, icon, photo.
|
||||
- Source: provided file, generated file, downloaded file, or chart from data.
|
||||
- Local path under the current working directory.
|
||||
- Intended placement and dimensions.
|
||||
|
||||
## Rules
|
||||
|
||||
- Slides XML cannot use HTTP(S) image URLs directly.
|
||||
- For a new deck using `+create --slides`, local image placeholders can use `src="@./path.png"`.
|
||||
- For existing decks or raw slide APIs, upload first with `slides +media-upload`, then use the returned `file_token`.
|
||||
- Keep source files inside the current working directory or a safe project subdirectory.
|
||||
- Check image dimensions and file size before upload; slides media upload limit is 20 MB.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Slides Planning Layer
|
||||
|
||||
Use this before writing XML for a full presentation or a major rewrite.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Goal: what decision, update, teaching outcome, or story the deck must support.
|
||||
- Audience: executives, customers, internal team, interview panel, students, or general readers.
|
||||
- Constraints: page count, language, source material, deadline, brand rules, required sections.
|
||||
- Success criteria: what the user should be able to inspect after creation.
|
||||
|
||||
## Output Outline
|
||||
|
||||
Use this compact structure:
|
||||
|
||||
```text
|
||||
Title: <deck title>
|
||||
Audience: <audience>
|
||||
Style: <visual direction or selected template>
|
||||
Slides:
|
||||
1. <slide title> - <message> - <layout intent>
|
||||
2. ...
|
||||
```
|
||||
|
||||
For formal reports, prefer this flow: cover, context, key findings, supporting evidence, implications, recommendations, next steps, closing.
|
||||
|
||||
## Rules
|
||||
|
||||
- Each slide gets one primary message.
|
||||
- Avoid document-like density; split overloaded pages.
|
||||
- Make charts or tables serve a stated point.
|
||||
- Confirm template choice when multiple good candidates would lead to materially different decks.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Slides Creation Validation Checklist
|
||||
|
||||
Use this after generating XML and again after creating or editing the deck.
|
||||
|
||||
## Before API Execution
|
||||
|
||||
- XML is well-formed.
|
||||
- User text is escaped: `&`, `<`, and `>` are safe.
|
||||
- Each slide has one clear message.
|
||||
- Text boxes are sized for expected content.
|
||||
- Images use `@./local-path` only where `+create --slides` supports it; otherwise they use `file_token`.
|
||||
- Run execution-layer lint when XML is in a file:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
## After Creation
|
||||
|
||||
- Record `xml_presentation_id`.
|
||||
- Read the full deck with `xml_presentations get`.
|
||||
- Confirm expected page count and page order.
|
||||
- Confirm key titles, body text, metrics, and image elements exist.
|
||||
- Check for blank pages, missing text, truncated shell arguments, unresolved `@` paths, and wrong image `src`.
|
||||
- Fix localized issues with `+replace-slide`; only delete/recreate a page when the whole structure is wrong.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Visual Planning
|
||||
|
||||
Use this to define the deck's visual system before generating slide XML.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Palette: background, primary accent, secondary accent, text, muted text, border.
|
||||
- Typography: title, section heading, body, caption, metric number.
|
||||
- Layout rhythm: margins, grid, recurring title position, footer treatment.
|
||||
- Components: cards, callouts, timelines, charts, tables, quote blocks, section dividers.
|
||||
|
||||
## Guidance
|
||||
|
||||
- Business reports should be quiet, readable, and scannable.
|
||||
- Product or technology decks can use stronger contrast, but keep hierarchy clear.
|
||||
- Use repeated structure across related slides.
|
||||
- Keep text inside predictable bounds; leave enough whitespace for rendering variance.
|
||||
- Do not rely on external image URLs in XML. Images must become `file_token` values through the execution workflow.
|
||||
|
||||
## XML Note
|
||||
|
||||
Before writing XML, read `../lark-slides/references/xml-schema-quick-ref.md`. Gradient fills must use `rgba()` stops with percentages.
|
||||
@@ -1,163 +1,525 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片执行层:通过 Slides XML/API 读取、创建、删除、替换幻灯片页面,处理 URL/wiki token、媒体上传、XML schema、格式校验与排障。"
|
||||
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
# slides execution layer
|
||||
# slides (v1)
|
||||
|
||||
> 创建完整 PPT、设计、美化、模板、素材、正式汇报场景请使用 lark-slides-creator。本 skill 只负责 XML/API 执行层。
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。**
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — 生成或修改任何 XML 之前,MUST 先读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)。不要凭记忆猜测 XML 结构。**
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
**CRITICAL — `references/slides_xml_schema_definition.xml` 是 Slides XML 协议的唯一权威来源;Markdown reference 只是摘要。若两者或 `lark-cli schema` 输出不一致,以 schema 和 CLI 为准。**
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
## Scope
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。生成本地 XML 后,如可运行 Python,MUST 先用 [`scripts/layout_lint.py`](scripts/layout_lint.py) 检查 XML well-formed、重叠/越界/文本高度风险,再创建或追加页面。它不是完整 XSD schema 校验。**
|
||||
|
||||
Use this skill for low-level execution tasks:
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
- Create an empty presentation or add raw slide XML.
|
||||
- Read presentation or slide XML.
|
||||
- Delete slides.
|
||||
- Replace or insert existing slide blocks.
|
||||
- Upload local media and use returned `file_token` in XML.
|
||||
- Resolve `/slides/` URL tokens and `/wiki/` tokens.
|
||||
- Check XML format, schema rules, and common API errors.
|
||||
## 身份选择
|
||||
|
||||
Do not use this skill as the primary entry for planning, visual design, template selection, asset planning, or full-deck creation. Route those requests to `lark-slides-creator`, then return here only for XML/API execution.
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
|
||||
## Identity
|
||||
|
||||
Slides are usually user-owned content. Default to explicit `--as user` for slides commands.
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain slides
|
||||
```
|
||||
|
||||
Use `--as bot` only when the user explicitly asks for app/bot identity or the workflow intentionally creates bot-owned resources. If access fails, first check that the command did not accidentally use the wrong identity.
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
|
||||
## URL And Wiki Tokens
|
||||
**执行规则**:
|
||||
|
||||
| URL | Token | Handling |
|
||||
| --- | --- | --- |
|
||||
| `/slides/<token>` | `xml_presentation_id` | Use the path token directly. |
|
||||
| `/wiki/<token>` | `wiki_token` | Resolve first with `wiki.spaces.get_node`; use `node.obj_token` only when `node.obj_type` is `slides`. |
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
一条命令创建包含页面内容的 PPT(推荐):
|
||||
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"obj_token"}'
|
||||
```
|
||||
|
||||
`+replace-slide` and `+media-upload` can parse slides/wiki URLs. Raw API commands still require the real `xml_presentation_id`.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Reference | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `slides +create` | [lark-slides-create.md](references/lark-slides-create.md) | Create a presentation; optionally add pages with `--slides`; supports local image placeholders in `+create --slides`. |
|
||||
| `slides +media-upload` | [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | Upload a local image to a presentation and return a `file_token`. |
|
||||
| `slides +replace-slide` | [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | Replace or insert blocks on an existing slide without changing page order. |
|
||||
|
||||
Prefer shortcuts when they cover the operation, especially `+replace-slide` for existing-slide edits.
|
||||
|
||||
## API Commands
|
||||
|
||||
Always inspect schema before raw API calls:
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method>
|
||||
lark-cli slides <resource> <method> --as user --params '{}' --data '{}'
|
||||
```
|
||||
|
||||
Core resources:
|
||||
|
||||
| Resource | Method | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `xml_presentations` | `get` | Read full presentation XML and metadata. |
|
||||
| `xml_presentation.slide` | `create` | Add one slide XML page. |
|
||||
| `xml_presentation.slide` | `delete` | Delete a slide; a presentation must keep at least one page. |
|
||||
| `xml_presentation.slide` | `get` | Read one slide XML. |
|
||||
| `xml_presentation.slide` | `replace` | Low-level block replace/insert API; prefer `+replace-slide` unless you need raw control. |
|
||||
|
||||
## Creation Paths
|
||||
|
||||
For simple XML, `+create --slides` is concise:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --as user --title "Demo" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(248,250,252)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>Title</p></content></shape></data></slide>"
|
||||
lark-cli slides +create --title "演示文稿标题" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245,245,245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>页面标题</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"200\"><content textType=\"body\"><p>正文内容</p><ul><li><p>要点一</p></li><li><p>要点二</p></li></ul></content></shape></data></slide>"
|
||||
]'
|
||||
```
|
||||
|
||||
For complex XML, long text, many special characters, Chinese paragraphs, images, or many pages, create an empty presentation first and add slides one by one. `+create --slides` is not atomic; if a later slide fails, earlier slides may already exist. Record `xml_presentation_id` and read the deck before continuing.
|
||||
也可以分两步(先创建空白 PPT,再逐页添加),详见 [+create 参考文档](references/lark-slides-create.md)。
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 适合简单页面批量创建,但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符,shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层是“先创建空白 PPT,再逐页调用 `xml_presentation.slide.create`”。这不是原子操作;中途某一页失败时,前面已创建成功的页面会保留。skill 必须把这种“部分成功”风险提前告诉用户,并在失败后先记录 `xml_presentation_id`,回读确认当前状态,再决定是否在现有 PPT 上继续修复或追加。
|
||||
|
||||
> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。
|
||||
|
||||
## 执行前必做
|
||||
|
||||
> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
### 必读(每次创建前)
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML 元素和属性速查,必读** |
|
||||
|
||||
### 选读(需要时查阅)
|
||||
|
||||
| 场景 | 文档 |
|
||||
|------|------|
|
||||
| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) |
|
||||
| 需要快速筛模板、做低成本路由 | [`scripts/template_tool.py search`](scripts/template_tool.py) |
|
||||
| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) |
|
||||
| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template_tool.py`](scripts/template_tool.py) |
|
||||
| 需要做本地布局风险检查 | [`scripts/layout_lint.py`](scripts/layout_lint.py) |
|
||||
| 需要 CLI 调用示例 | [examples.md](references/examples.md) |
|
||||
| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) |
|
||||
| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema) |
|
||||
| 需要编辑已有 PPT 的单个页面 | [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) |
|
||||
| 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) |
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --as user --title "Demo"
|
||||
# 1. 搜索候选:把用户原始需求整句放进 --query,不要只放手动提炼的短词
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
|
||||
lark-cli slides xml_presentation.slide create --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0"><data/></slide>' '{slide:{content:$content}}')"
|
||||
# 2. 锁定模板后先看页型摘要
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
|
||||
# 3. 只有需要复用布局骨架时才裁切 XML
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
|
||||
# 4. 生成待创建 XML 后先做布局风险检查
|
||||
python3 skills/lark-slides/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
To insert before an existing page, put `before_slide_id` in `--data`, not in `--params`.
|
||||
执行规则:
|
||||
|
||||
## Media Upload
|
||||
1. `search --query` 使用用户原始描述;如用户明确风格,再额外加 `--tone light|dark|colorful` 或 `--formality formal|casual|creative`。
|
||||
2. 候选展示只给 2-3 个,包含模板名、适用场景、风格/色调、推荐理由;不要把完整目录贴给用户。
|
||||
3. 锁定模板后,复用 `<theme>`、配色、页面流、布局骨架;所有占位文案都必须改写为用户真实内容。
|
||||
4. `layout_lint.py` 有 error 时先修 XML,不要提交创建;只有 warning 时,检查是否是可接受的装饰/背景误报。
|
||||
|
||||
Slides XML image `src` must be a Lark `file_token`; do not use external HTTP(S) URLs.
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清用户需求:主题、受众、页数、风格偏好
|
||||
- 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由)
|
||||
- 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选
|
||||
- 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底
|
||||
- 如果用户没有明确风格,根据主题推荐(见下方风格判断表)
|
||||
- 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景:
|
||||
· 优先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3` 做低成本模板匹配
|
||||
· 需要人类可读说明时,再读 template-catalog.md 组织候选文案
|
||||
· 锁定模板后,优先运行 `template_tool.py summarize` 看 `<theme>` / 页型摘要;需要具体布局时,再用 `template_tool.py extract`
|
||||
· 复用模板的 theme、配色、页面流、布局骨架,不要照搬占位文案
|
||||
· `references/template-index.json` 只是脚本缓存/轻量路由索引,`assets/templates/*.xml` 是机器资源;除非用户明确要求审计原始模板,否则不要直接读取
|
||||
- 读取 XML Schema 参考:
|
||||
· xml-schema-quick-ref.md — 元素和属性速查
|
||||
· xml-format-guide.md — 详细结构与示例
|
||||
· slides_demo.xml — 真实 XML 示例
|
||||
|
||||
- New deck with `+create --slides`: `src="@./local.png"` is allowed and the shortcut uploads it.
|
||||
- Existing deck or raw `slide.create`: run `slides +media-upload` first, then write `src="<file_token>"`.
|
||||
- Existing slide edit: upload first, then use `+replace-slide` with `block_insert` or `block_replace`.
|
||||
Step 2: 生成大纲 → 用户确认 → 创建
|
||||
- 生成大纲前,先确认用户是否采用推荐模板;轻量任务且候选中有明显最佳匹配时,可在大纲里声明“默认基于 <template-id> 改写”并继续,但正式创建前必须给用户改选机会
|
||||
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
|
||||
- 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写”
|
||||
- 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择
|
||||
- 先判断创建方式:
|
||||
· 简单 XML:可用 `slides +create --slides '[...]'` 一步创建
|
||||
· 复杂 XML:优先先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
· 超过 10 页:默认使用两步创建,避免单次输入过长
|
||||
- 含本地图片:
|
||||
· 新建带图 PPT —— 在 slide XML 里写 <img src="@./pic.png" .../>,
|
||||
+create 会自动上传并替换为 file_token(详见 lark-slides-create.md)
|
||||
· 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID`
|
||||
拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create
|
||||
· 给已有页加图 —— 两步:① `slides +media-upload` 拿 file_token
|
||||
② `slides +replace-slide --parts '[{"action":"block_insert","insertion":"<img src=\"<file_token>\" .../>"}]'`
|
||||
不动其他元素,不要再整页重建(完整示例见 lark-slides-edit-workflows.md 的 block_insert 章节)
|
||||
· 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png);
|
||||
绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行
|
||||
- 每页 slide 需要完整的 XML:背景、文本、图形、配色
|
||||
- 复杂元素(table、chart)需参考 XSD 原文
|
||||
- 创建前必须做 XML 自检:
|
||||
· 检查特殊字符是否按 XML 规则转义:文本节点和属性值里的裸 `& -> &`;文本里的 `< -> <`、`> -> >`。例如 `Q&A -> Q&A`,URL 属性 `a=1&b=2 -> a=1&b=2`
|
||||
· 属性值里的双引号必须转义或改为外层安全包装,避免 shell 和 JSON 双重截断
|
||||
· 确认所有标签闭合,且 `<slide>` 直接子元素只包含 `<style>`、`<data>`、`<note>`
|
||||
· 如果内容里同时出现中文、大段文本、复杂布局、较多特殊字符,默认不要走 `--slides '[...]'`,直接改用两步创建法
|
||||
· 如果 XML 已落到本地文件且可运行 Python,先执行 `layout_lint.py --input <file>`;它会先检查 XML well-formed 再检查布局风险,但不等价于完整 XSD schema 校验;有 error 先修复再创建
|
||||
- 如果使用模板生成页面,先复用模板骨架再填内容,不要直接复制模板中的长段占位文本
|
||||
|
||||
Local paths must be safe paths under the current working directory. The upload limit is 20 MB.
|
||||
Step 3: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML 做创建后验证,确认:
|
||||
· 页数是否正确?
|
||||
· 每页 `<data>` 是否包含预期的 `<shape>` / `<img>` / 其他元素?
|
||||
· 文本内容是否完整,是否有被截断、丢失、空白区域?
|
||||
· 关键布局坐标和尺寸是否合理,是否出现明显重叠?
|
||||
· 配色是否统一?字号层级是否合理?
|
||||
- 如果本地有 Python 3,运行
|
||||
`python3 skills/lark-slides/scripts/layout_lint.py --input presentation.xml`
|
||||
做重叠、越界、页脚碰撞、文本高度风险检查;有 error 先修复再交付
|
||||
- 如果创建过程中失败:
|
||||
· 先保留并记录 `xml_presentation_id`,不要假设失败代表什么都没创建
|
||||
· 先判断是否已有部分页面写入,再决定是否在现有 PPT 上修复后继续追加
|
||||
· 优先排查当前失败页:先看该页 XML,再检查是否存在未转义 `&`、错误引号、标签未闭合、shell 传参截断
|
||||
- 局部问题 → 用 `+replace-slide` 块级修正;整页结构要改 → `slide.delete` 旧页 + `slide.create` 新页
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
## XML Rules
|
||||
### 创建后验证
|
||||
|
||||
- `<slide>` direct children are only `<style>`, `<data>`, and `<note>`.
|
||||
- Text belongs inside `<content><p>...</p></content>`.
|
||||
- Escape raw text before writing XML: `&` becomes `&`, text `<` becomes `<`, and text `>` becomes `>`.
|
||||
- Gradient fills require `rgba()` stops with percentages, for example `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`.
|
||||
- For `xml_presentation.slide.replace`, `block_replace` needs the target block id and text shapes need `<content/>`; `+replace-slide` injects the required wrapper details.
|
||||
创建成功不等于内容正确。创建完 PPT 后,**必须**读取全文 XML 校验结果:
|
||||
|
||||
## Validation
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
This execution skill validates at the XML/API layer. Before execution, check XML well-formedness, escaping, request body shape, and `lark-cli schema` output. Visual layout quality checks belong to creator workflows, not this execution layer.
|
||||
重点检查:
|
||||
|
||||
## Troubleshooting
|
||||
- [ ] 页数是否与预期一致
|
||||
- [ ] 每页 `<data>` 中是否包含所有预期元素
|
||||
- [ ] 文本内容是否完整,没有被 shell 截断或转义损坏
|
||||
- [ ] 白底内容区、卡片区、图文区等关键布局是否实际生成
|
||||
- [ ] 坐标、宽高是否合理,是否出现堆叠或越界
|
||||
|
||||
| Symptom | Likely Cause | Next Action |
|
||||
| --- | --- | --- |
|
||||
| `400` XML or wrapper error | Bad XML or wrong `--data` shape | Check escaping, tag closure, and `lark-cli schema`. |
|
||||
| `403` permission denied | Wrong identity or missing scope | Confirm `--as user` vs `--as bot`; re-run auth for slides scope. |
|
||||
| `404` presentation/slide not found | Wrong token or unresolved wiki URL | Resolve wiki token or re-read current presentation. |
|
||||
| `1061002` media params error | Raw upload API used incorrectly | Use `slides +media-upload`; slides parent type is `slide_file`. |
|
||||
| `1061004` forbidden | Current identity cannot edit target deck | Use the owner identity or share the deck with the bot/user. |
|
||||
| `3350001` catch-all validation failure | XML not well-formed, bad replace wrapper, missing `<content/>`, or unescaped text | Run lint, inspect failed page XML, and prefer `+replace-slide` for block edits. |
|
||||
| `3350002` stale revision | `revision_id` is newer than current | Use `-1` or re-read the presentation and retry. |
|
||||
| Created deck has blank/missing pages | Shell/JSON argument truncation or escaping issue | Read back XML, then continue with two-step `slide.create`. |
|
||||
| Image does not show | `src` is URL or unresolved `@path` | Upload and replace with a `file_token`. |
|
||||
发现问题时:
|
||||
|
||||
## References
|
||||
1. 不要假设“创建成功就代表渲染正确”
|
||||
2. 先读取问题页的 XML,确认是生成问题还是传参损坏
|
||||
3. 删除问题页后重新添加;复杂页面优先改用两步创建法
|
||||
|
||||
| Reference | Purpose |
|
||||
| --- | --- |
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | Required XML element and attribute quick reference. |
|
||||
| [xml-format-guide.md](references/xml-format-guide.md) | Detailed XML structure and examples. |
|
||||
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | Full XML schema definition. |
|
||||
| [lark-slides-create.md](references/lark-slides-create.md) | `+create` shortcut. |
|
||||
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | `+media-upload` shortcut. |
|
||||
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | `+replace-slide` shortcut. |
|
||||
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | Existing-slide read/modify/write workflows. |
|
||||
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | Raw presentation read API. |
|
||||
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | Raw slide create API. |
|
||||
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | Raw slide delete API. |
|
||||
| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | Raw slide get API. |
|
||||
| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | Raw slide replace API. |
|
||||
| [examples.md](references/examples.md) | CLI examples. |
|
||||
| [slides_demo.xml](references/slides_demo.xml) | Example presentation XML. |
|
||||
### 最小验收清单
|
||||
|
||||
创建完成后,默认按下面顺序验收,不要省略:
|
||||
|
||||
1. 记录 `xml_presentation_id`
|
||||
2. 确认返回的 `slides_added` 或实际页数是否符合预期
|
||||
3. 立即执行 `xml_presentations get`
|
||||
4. 检查标题、关键页面、关键文本是否存在
|
||||
5. 检查是否有明显空白页、内容缺失、页序错误
|
||||
6. 再决定是否向用户交付 URL 和后续编辑建议
|
||||
|
||||
推荐最小闭环:
|
||||
|
||||
```bash
|
||||
# 创建
|
||||
lark-cli slides +create --as user --title "Demo" --slides '[...]'
|
||||
|
||||
# 立即回读
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
## XML 自检与排障
|
||||
|
||||
在真正创建前,至少做下面 4 项检查:
|
||||
|
||||
- [ ] 特殊字符已转义:正文和标题里的 `&`、`<`、`>` 不能裸写;属性值里的裸 `&` 也必须写成 `&`
|
||||
- [ ] 属性引号安全:XML 属性、shell 引号、JSON 字符串包装之间没有互相打断
|
||||
- [ ] 结构合法:`<slide>` 下只放 `<style>`、`<data>`、`<note>`,文本都在 `<content>` 内
|
||||
- [ ] 路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用
|
||||
|
||||
高频失败信号和处理顺序:
|
||||
|
||||
1. `invalid param` / 某一页创建失败
|
||||
2. 先检查失败页是否含未转义 `&` / `<` / `>`:`Q&A -> Q&A`,属性 URL `a=1&b=2 -> a=1&b=2`
|
||||
3. 再检查标签闭合、属性引号、`<content>` 结构
|
||||
4. 如果是 `--slides '[...]'`,怀疑 shell 截断时直接切两步创建法
|
||||
5. 创建后无论成功失败,都优先记录 `xml_presentation_id` 并回读确认是否已有部分页面写入
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--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 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级
|
||||
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
```
|
||||
|
||||
### 风格快速判断表
|
||||
|
||||
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
| 场景/主题 | 推荐风格 | 背景 | 主色 | 文字色 |
|
||||
|----------|---------|------|------|-------|
|
||||
| 科技/AI/产品 | 深色科技风 | 深蓝渐变 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)` | 蓝色系 `rgb(59,130,246)` | 白色 |
|
||||
| 商务汇报/季度总结 | 浅色商务风 | 浅灰 `rgb(248,250,252)` | 深蓝 `rgb(30,60,114)` | 深灰 `rgb(30,41,59)` |
|
||||
| 教育/培训 | 清新明亮风 | 白色 `rgb(255,255,255)` | 绿色系 `rgb(34,197,94)` | 深灰 `rgb(51,65,85)` |
|
||||
| 创意/设计 | 渐变活力风 | 紫粉渐变 `linear-gradient(135deg,rgba(88,28,135,1) 0%,rgba(190,24,93,1) 100%)` | 粉紫色系 | 白色 |
|
||||
| 周报/日常汇报 | 简约专业风 | 浅灰 `rgb(248,250,252)` + 顶部彩色渐变条 | 蓝色 `rgb(59,130,246)` | 深色 `rgb(15,23,42)` |
|
||||
| 用户未指定 | 默认简约专业风 | 同上 | 同上 | 同上 |
|
||||
|
||||
### 页面布局建议
|
||||
|
||||
| 页面类型 | 布局要点 |
|
||||
|---------|---------|
|
||||
| 封面页 | 居中大标题 + 副标题 + 底部信息,背景用渐变或深色 |
|
||||
| 数据概览页 | 指标卡片横排(rect 背景 + 大号数字 + 小号说明),下方列表或图表 |
|
||||
| 内容页 | 左侧竖线装饰 + 标题,下方分栏或列表 |
|
||||
| 对比/表格页 | table 元素或并列卡片,表头深色背景白字 |
|
||||
| 图表页 | chart 元素(column/line/pie),配合文字说明 |
|
||||
| 结尾页 | 居中感谢语 + 装饰线,风格与封面呼应 |
|
||||
|
||||
### 大纲模板
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
|
||||
模板:[未使用模板 / <category>/<template>.xml(推荐原因)]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
|
||||
### 常用 Slide XML 模板
|
||||
|
||||
可直接复制使用的模板(封面页、内容页、数据卡片页、结尾页):[slide-templates.md](references/slide-templates.md)
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### URL 格式与 Token
|
||||
|
||||
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|
||||
|----------|------|-----------|----------|
|
||||
| `/slides/` | `https://example.larkoffice.com/slides/xxxxxxxxxxxxx` | `xml_presentation_id` | URL 路径中的 token 直接作为 `xml_presentation_id` 使用 |
|
||||
| `/wiki/` | `https://example.larkoffice.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
|
||||
|
||||
> `+replace-slide` 和 `+media-upload` shortcut 会自动解析以上两种 URL;直接调用原生 API 时仍需手动解析 wiki 链接。
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、幻灯片等不同类型的文档。**不能直接假设 URL 中的 token 就是 `xml_presentation_id`**,必须先查询实际类型和真实 token。
|
||||
|
||||
#### 处理流程
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型,幻灯片对应 `slides`
|
||||
- `node.obj_token`:**真实的演示文稿 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
|
||||
3. **确认 `obj_type` 为 `slides` 后,使用 `obj_token` 作为 `xml_presentation_id`**
|
||||
|
||||
#### 查询示例
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wikcnxxxxxxxxx"}'
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "slides",
|
||||
"obj_token": "xxxxxxxxxxxx",
|
||||
"title": "2026 产品年度总结",
|
||||
"node_type": "origin",
|
||||
"space_id": "1234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 用 obj_token 读取幻灯片内容
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"xxxxxxxxxxxx"}'
|
||||
```
|
||||
|
||||
### 资源关系
|
||||
|
||||
```text
|
||||
Wiki Space (知识空间)
|
||||
└── Wiki Node (知识库节点, obj_type: slides)
|
||||
└── obj_token → xml_presentation_id
|
||||
|
||||
Slides (演示文稿)
|
||||
├── xml_presentation_id (演示文稿唯一标识)
|
||||
├── revision_id (版本号)
|
||||
└── Slide (幻灯片页面)
|
||||
└── slide_id (页面唯一标识)
|
||||
```
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传),bot 模式自动授权 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### xml_presentations
|
||||
|
||||
- `get` — 读取演示文稿全文信息,XML 格式返回
|
||||
|
||||
### xml_presentation.slide
|
||||
|
||||
- `create` — 在指定 XML 演示文稿下创建页面
|
||||
- `delete` — 在指定 XML 演示文稿下删除页面
|
||||
- `get` — 获取指定 XML 演示文稿的单个页面 XML 内容
|
||||
- `replace` — 对指定 XML 演示文稿页面进行元素级别的局部替换
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先定模板/风格并出大纲再动手**:如果需求可匹配模板,先给用户 2-3 个模板候选;模板或自定义风格确定后,再生成大纲交给用户确认,避免返工
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) |
|
||||
| `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `slides +replace-slide` | `slides:presentation:update`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `xml_presentations.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` |
|
||||
| `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` |
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
## 常见错误速查
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 |
|
||||
| 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` |
|
||||
| 创建成功但页面空白/内容缺失/布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建:先 `slides +create`,再用 `jq -n` 包装 `xml_presentation.slide.create` 逐页添加,并在创建后立即读取 XML 验证 |
|
||||
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
|
||||
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
|
||||
| 403 | 权限不足 | 检查是否拥有对应的 scope |
|
||||
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
|
||||
| 1061002 | params error(媒体上传时) | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
|
||||
| 1061004 | forbidden:当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 `xml_presentation.slide.replace` 失败(catch-all) | 优先检查未转义 `&` / `<` / `>`:`Q&A -> Q&A`,属性 URL `a=1&b=2 -> a=1&b=2`;运行 `layout_lint.py --input <file>` 定位行列和上下文;再检查 replace 场景的 `block_id` / `<content/>` / 坐标 |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
|
||||
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
|
||||
|
||||
## 创建前自查
|
||||
|
||||
逐页生成 XML 前,快速检查:
|
||||
|
||||
- [ ] 每页背景色/渐变是否设置?风格是否与整体一致?
|
||||
- [ ] 标题用大字号(28-48),正文用小字号(13-16),层级分明?
|
||||
- [ ] 同类元素配色一致?(如所有指标卡片同色系、所有正文同色)
|
||||
- [ ] 装饰元素(分割线、色块、竖线)颜色是否与主色协调?
|
||||
- [ ] 文本框尺寸是否足够容纳内容?(宽度 × 高度)
|
||||
- [ ] shape 的 `type` 是否正确?(文本框用 `text`,装饰用 `rect`)
|
||||
- [ ] XML 标签是否全部正确闭合?特殊字符(`&`、`<`、`>`)是否转义?
|
||||
|
||||
## 症状 → 修复表
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
|-----------|--------|
|
||||
| 文字被截断/看不全 | 增大 shape 的 `width` 或 `height` |
|
||||
| 元素重叠 | 调整 `topLeftX`/`topLeftY`,拉开间距 |
|
||||
| 页面大面积空白 | 缩小元素间距,或增加内容填充 |
|
||||
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
|
||||
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
|
||||
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 |
|
||||
| 图片被裁掉一部分 | `<img>` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 |
|
||||
| 只想改某页的单个元素(文字/图片/形状) | 用 `+replace-slide` 块级替换,不要整页重建 |
|
||||
| 想给已有页加一张图(不动原有元素) | ① `+media-upload` 拿 `file_token` ② `+replace-slide` 用 `block_insert` 插入 `<img src="<file_token>" .../>`;不要再用 "整页 create + delete" 的老流程 |
|
||||
| 新插入的 `<img>` 挡住/重叠原有元素 | `slide.get` 读原页,对照已有块的 `topLeftX/Y/width/height` 挑空白位置;空间不够就在同一批 `--parts` 里先 `block_replace` 缩小/挪动现有块再 `block_insert` 图片 |
|
||||
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 |
|
||||
| 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) |
|
||||
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
|
||||
| API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 |
|
||||
| API 返回 3350001 | `block_replace` 根元素缺 `id=<block_id>` 或 `<shape>` 缺 `<content/>`,详见 replace-slide 文档 |
|
||||
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src |
|
||||
| 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`(slides 唯一接受值);不要手拼,用 `slides +media-upload` |
|
||||
|
||||
## 参考文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut:创建 PPT(支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片)** |
|
||||
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut:上传本地图片,返回 `file_token`** |
|
||||
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | **+replace-slide Shortcut:块级替换/插入,含合法根元素速查与 3350001 排错** |
|
||||
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | 编辑已有页面的读-改-写流程与 action 决策树 |
|
||||
| [template-index.json](references/template-index.json) | **脚本缓存/轻量路由索引:由 `template_tool.py search` 使用,不是默认阅读入口** |
|
||||
| [template-catalog.md](references/template-catalog.md) | **按场景/色调匹配现成 PPT 模板,并定位到页型范围** |
|
||||
| [`scripts/template_tool.py`](scripts/template_tool.py) | **可选 Python 辅助脚本:`search` / `summarize` / `extract`,支持 `--layout-tag` 与 `extract --with-summary`** |
|
||||
| [`scripts/layout_lint.py`](scripts/layout_lint.py) | **本地预检脚本:先检查 XML well-formed,再检测重叠、越界、页脚碰撞、文本高度风险;不是完整 XSD schema 校验** |
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
|
||||
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
|
||||
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |
|
||||
| [examples.md](references/examples.md) | CLI 调用示例 |
|
||||
| [slides_demo.xml](references/slides_demo.xml) | 真实 PPT 的完整 XML |
|
||||
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | **完整 Schema 定义**(唯一协议依据) |
|
||||
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | 读取 PPT 命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | 添加幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | 删除幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | 读取单个幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | 原生 slide.replace API 命令详情 |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user