Compare commits

..

16 Commits

Author SHA1 Message Date
liangshuo-1
714da970d0 chore(release): v1.0.55 (#1490) 2026-06-16 22:26:40 +08:00
sang-neo03
ed7fdd1a27 feat: optimize event subscription precheck, links, and consumer guard (#1447)
* feat: add SubscriptionType and SingleConsumer to EventKey definition

* feat: fetch subscribed callbacks from application/get

* feat: build addons scan-to-enable deep link for event precheck

* feat: route callback precheck to application/get and emit scan links

* feat: add reject fields to hello_ack protocol message

* feat: add exclusive registration to event bus hub

* feat: reject duplicate consumer for SingleConsumer EventKey at bus handshake

* feat: surface bus consumer rejection as failed_precondition error

* fix: encode empty addons sides as [] not null per launcher contract

* fix: report missing callbacks when console has none subscribed

* feat: bound exclusive consumer cleanup wait with configurable timeout

* refactor: drain exclusive-wait timer and document websocket-only callbacks

* fix: use camelCase clientID param in event scan-to-enable link

* test: cover null/omitted callbacks and assert typed error category

* fix: keep auth login remediation for user-identity missing scopes

* refactor: simplify SubscriptionType normalization to match validateAuth style
2026-06-16 19:41:52 +08:00
wangweiming-01
4464ba7660 fix: validate drive import folder target (#1485)
Change-Id: I43755c3966b0daa06b708d2b3d03294f439547fa
2026-06-16 18:14:08 +08:00
zhicong666-bytedance
bb03c8ac4d feat(vc): support agent meeting event workflows (#1483)
* feat: support vc agent active meetings

* docs: clarify vc agent active meeting flow

* fix: align active meeting shortcut scope

* docs: clarify active meeting id fields

* fix: reject meeting numbers for vc events

* docs: clarify vc agent active meeting flow

* docs: refine vc agent meeting flow guidance

* docs: address vc agent skill review feedback

* docs: clarify vc meeting product wording

* docs: align vc agent skill with quality guidelines

* docs: trim vc agent skill token budget

* Revert "docs: trim vc agent skill token budget"

This reverts commit 8560bb9c19.
2026-06-16 18:08:07 +08:00
yballul-bytedance
3feb70b32a feat(drive): 支持导出 Base 结构快照 (#1481)
1. 为 drive +export 增加 --only-schema 参数,并透传 only_schema 到导出任务请求。
2. 限制该参数仅用于 bitable 导出 .base,并补充单测与 dry-run E2E 覆盖。

Change-Id: I736cebf5841cc1c6acaa8a3ab16be51ba4cb355d
2026-06-16 16:36:31 +08:00
ZEden0
64b1b3f3ed feat(docs): support lang for fetch v2 (#1459) 2026-06-16 16:25:36 +08:00
ZEden0
a0e83c7e59 feat(docs): add docx cover resource commands (#1468)
Spec source: active@bd186a6373948acc76d8b0872334b1a53ad40f5645b1a4e129937d7a51f5596c
2026-06-16 15:25:37 +08:00
liangshuo-1
297b2a222e chore(release): v1.0.54 (#1476) 2026-06-15 21:58:07 +08:00
Zhang-986
80a5f30f4d fix(event): clarify remote bus blocker recovery (#1454) 2026-06-15 20:27:59 +08:00
xzcong0820
cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

- Add exported PlainTextFromHTML wrapper in draft/htmltext.go
- Add DefaultSendID/DefaultReplyID in signature/provider.go
- Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict,
  injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText
- mail_send, mail_draft_create: add --no-signature flag, auto-resolve default
  signature when no --signature-id given, inject plain-text sig in plain-text branch
- mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix
  (resolveSignature moved to after senderEmail is finalized)
- Update 5 reference docs: add --no-signature row, update --plain-text and
  --signature-id descriptions

---------

Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
2026-06-15 20:04:05 +08:00
fangshuyu-768
fd16cf106b clarify lark-doc create title guidance (#1474) 2026-06-15 19:38:56 +08:00
1uckypeach
53076733ec docs(skills): add rename prompt for import without --name (#1461)
When --name is omitted, remind user that the title defaults to the source
filename and may duplicate content headings, causing visual redundancy.
Ask whether to rename before executing the import.
2026-06-15 19:30:51 +08:00
陈家名
a3bee13ca9 fix(vfs): reject blank local paths (#1460) 2026-06-15 19:14:31 +08:00
fangshuyu-768
6217bd2c29 fix docs fetch and update ergonomics (#1466) 2026-06-15 17:47:34 +08:00
search_zhuhao
72c294712c feat: 【larksuite/cli】【drive 搜索支持 original_creator_ids】 M-7074213537 (#1046)
sa: none

fg: none

cfg: none

doc: none

test: ppe
Change-Id: I88bedd02a5daa3307b05c9b6f94748e1544d279a
2026-06-15 14:18:45 +08:00
sammi-bytedance
37f4f899b2 docs(lark-im): document @mention format per message type (text/post/card) (#1419)
Split the send/reply @Mention sections by message type:
- text: <at user_id="ou_xxx">name</at> (inner name optional), @all
- post: inline form in text/md elements, or a dedicated {"tag":"at"} node
- interactive card: card-native <at id=>, <at ids=>, <at email=>
2026-06-15 14:08:50 +08:00
130 changed files with 5990 additions and 8299 deletions

View File

@@ -2,6 +2,46 @@
All notable changes to this project will be documented in this file.
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
@@ -1149,6 +1189,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -4,21 +4,117 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
}

View File

@@ -4,33 +4,109 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
}
}

View File

@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -229,6 +243,9 @@ type preflightCtx struct {
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
@@ -266,46 +283,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "),
)
strings.Join(missing, " "))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID))
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,9 +157,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

12
go.mod
View File

@@ -27,8 +27,6 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/apache/arrow/go/v17 v17.0.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -44,17 +42,13 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -63,16 +57,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

32
go.sum
View File

@@ -2,8 +2,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -54,16 +52,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -80,16 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -108,8 +97,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -146,20 +133,14 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -175,7 +156,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -189,16 +169,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"fmt"
)
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
// from application/get. On a successful fetch it always returns a non-nil slice
// (empty when callback_info is absent or lists no callbacks) so callers can
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
// that must fail the precheck — from a fetch error (nil), which is a
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
App struct {
CallbackInfo *struct {
SubscribedCallbacks []string `json:"subscribed_callbacks"`
} `json:"callback_info"`
} `json:"app"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode application response: %w", err)
}
// callback_info also carries callback_type (e.g. "websocket"); it is
// intentionally not parsed or validated. Feishu open-platform callbacks are
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
// event source, so subscribed_callbacks alone is sufficient for the precheck.
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
callbacks := []string{}
if ci := envelope.Data.App.CallbackInfo; ci != nil {
callbacks = append(callbacks, ci.SubscribedCallbacks...)
}
return callbacks, nil
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"errors"
"testing"
)
var errFakeFetch = errors.New("fake fetch error")
type fakeCallbackClient struct {
raw string
err error
}
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if f.err != nil {
return nil, f.err
}
return json.RawMessage(f.raw), nil
}
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"card.action.trigger", "profile.view.get"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
// A successful fetch with no callback_info means "zero callbacks subscribed",
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
// the precheck reports a required callback as missing instead of skipping.
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
if err == nil {
t.Fatal("expected error")
}
if got != nil {
t.Errorf("got %v, want nil on fetch error", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
// callback_info present but subscribed_callbacks explicitly null → must be
// a non-nil empty slice so the precheck reports missing callbacks.
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}

View File

@@ -265,8 +265,8 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
DefaultAs: app.DefaultAs,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -132,6 +132,27 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
}
}
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
Lang: "en",
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want %q", cfg.Lang, "en")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain),

View File

@@ -269,8 +269,26 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
bc.SetLogger(b.logger)
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey := b.hub.RegisterAndIsFirst(bc)
// SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake.
exclusive := false
if def, ok := event.Lookup(hello.EventKey); ok {
exclusive = def.SingleConsumer
}
var firstForKey bool
if exclusive {
ok, reason := b.hub.TryRegisterExclusive(bc)
if !ok {
if err := bc.writeFrame(protocol.NewHelloAckRejected("v1", reason)); err != nil {
b.logger.Printf("WARN: reject hello_ack write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, err)
}
bc.Close()
return
}
firstForKey = true
} else {
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey = b.hub.RegisterAndIsFirst(bc)
}
bc.SetCheckLastForKey(func(scope string) bool {
return b.hub.AcquireCleanupLock(scope)

View File

@@ -5,12 +5,15 @@ package bus
import (
"bufio"
"bytes"
"io"
"log"
"net"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
@@ -194,3 +197,60 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
t.Fatal("HelloAck was empty")
}
}
// TestHandleHello_SingleConsumerRejectsSecond: a SingleConsumer EventKey accepts
// the first consumer and rejects the second for the same SubscriptionID.
func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) {
const key = "test.handlehello.exclusive"
event.RegisterKey(event.KeyDefinition{
Key: key,
EventType: key,
SingleConsumer: true,
Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer event.UnregisterKeyForTest(key)
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
readAck := func(t *testing.T, pid int) *protocol.HelloAck {
t.Helper()
server, client := net.Pipe()
t.Cleanup(func() { server.Close(); client.Close() })
hello := &protocol.Hello{PID: pid, EventKey: key, EventTypes: []string{key}}
go b.handleHello(server, bufio.NewReader(server), hello)
line, err := protocol.ReadFrame(bufio.NewReader(client))
if err != nil {
t.Fatalf("read ack (pid %d): %v", pid, err)
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
t.Fatalf("decode ack (pid %d): %v", pid, err)
}
ack, ok := msg.(*protocol.HelloAck)
if !ok {
t.Fatalf("got %T, want *HelloAck", msg)
}
return ack
}
ack1 := readAck(t, 100)
if ack1.Rejected {
t.Fatalf("first consumer should be accepted, got rejected: %q", ack1.RejectReason)
}
ack2 := readAck(t, 200)
if !ack2.Rejected {
t.Fatal("second consumer should be rejected")
}
if !strings.Contains(ack2.RejectReason, "already running") {
t.Errorf("reject reason = %q, want mention of 'already running'", ack2.RejectReason)
}
}

View File

@@ -6,13 +6,34 @@ package bus
import (
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
// exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an
// in-progress cleanup of the same subscription before rejecting, so a stuck
// cleanup can never wedge new consumers forever. Kept below the consumer's
// hello_ack deadline (consume.helloAckTimeout = 5s) so the reject still reaches
// the consumer as a clean failed_precondition instead of a handshake timeout.
// Override with LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT (a Go duration such as
// "2s"); values at or above the 5s handshake deadline are not recommended.
var exclusiveCleanupWaitTimeout = resolveExclusiveCleanupWaitTimeout()
func resolveExclusiveCleanupWaitTimeout() time.Duration {
const def = 3 * time.Second
if v := os.Getenv("LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return def
}
// Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface {
EventKey() string
@@ -124,6 +145,63 @@ func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
}
}
// TryRegisterExclusive registers s only when no subscriber holds s.SubscriptionID()
// and any in-progress cleanup for that subscription finishes within
// exclusiveCleanupWaitTimeout. On failure it returns (false, reason): either a
// duplicate consumer already holds the subscription, or the cleanup did not
// finish in time — the timeout guarantees a stuck cleanup can never wedge new
// consumers forever. reason is "" on success. Mirrors RegisterAndIsFirst's wait
// on in-progress cleanup, but bounded.
func (h *Hub) TryRegisterExclusive(s Subscriber) (bool, string) {
sid := s.SubscriptionID()
deadline := time.Now().Add(exclusiveCleanupWaitTimeout)
for {
h.mu.Lock()
ch, locked := h.cleanupInProgress[sid]
if locked {
h.mu.Unlock()
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
}
timer := time.NewTimer(remaining)
select {
case <-ch:
// Stop+drain so a timer that fired concurrently with Stop isn't left on .C.
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
continue
case <-timer.C:
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
}
}
if h.subCounts[sid] != 0 {
pid := h.existingPIDForSubscriptionLocked(sid)
h.mu.Unlock()
return false, fmt.Sprintf("another consumer (pid %d) is already running for this subscription", pid)
}
h.subscribers[s] = struct{}{}
h.subCounts[sid]++
h.mu.Unlock()
return true, ""
}
}
// existingPIDForSubscriptionLocked returns the PID of one subscriber for sid.
// Caller must hold h.mu.
func (h *Hub) existingPIDForSubscriptionLocked(sid string) int {
for sub := range h.subscribers {
if sub.SubscriptionID() == sid {
return sub.PID()
}
}
return 0
}
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
//
// A fresh *protocol.Event is allocated per subscriber so each consumer sees

View File

@@ -6,6 +6,7 @@ package bus
import (
"encoding/json"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -355,3 +356,69 @@ func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
}
}
func TestHub_TryRegisterExclusive(t *testing.T) {
h := NewHub()
first := newTestConn("k.exclusive", []string{"k.exclusive"})
first.pid = 100
ok, _ := h.TryRegisterExclusive(first)
if !ok {
t.Fatal("first exclusive register should succeed")
}
second := newTestConn("k.exclusive", []string{"k.exclusive"})
second.pid = 200
ok, reason := h.TryRegisterExclusive(second)
if ok {
t.Error("second exclusive register should be rejected")
}
if !strings.Contains(reason, "pid 100") {
t.Errorf("reject reason = %q, want it to name existing pid 100", reason)
}
if got := h.SubCount("k.exclusive"); got != 1 {
t.Errorf("SubCount = %d, want 1 (second not registered)", got)
}
}
func TestHub_TryRegisterExclusive_CleanupWaitTimeout(t *testing.T) {
// A cleanup lock that never releases must not wedge a new exclusive consumer
// forever — TryRegisterExclusive bounds the wait and rejects with a timeout reason.
saved := exclusiveCleanupWaitTimeout
exclusiveCleanupWaitTimeout = 20 * time.Millisecond
defer func() { exclusiveCleanupWaitTimeout = saved }()
h := NewHub()
first := newTestConn("k.timeout", []string{"k.timeout"})
if ok, _ := h.TryRegisterExclusive(first); !ok {
t.Fatal("first exclusive register should succeed")
}
// Hold the cleanup lock and never release it.
if !h.AcquireCleanupLock("k.timeout") {
t.Fatal("AcquireCleanupLock should succeed for the sole subscriber")
}
start := time.Now()
second := newTestConn("k.timeout", []string{"k.timeout"})
ok, reason := h.TryRegisterExclusive(second)
if ok {
t.Error("second exclusive register should be rejected on cleanup-wait timeout")
}
if !strings.Contains(reason, "timed out") {
t.Errorf("reject reason = %q, want a timeout reason", reason)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("wait took %v, want bounded by the ~20ms timeout (no deadlock)", elapsed)
}
}
func TestHub_TryRegisterExclusive_DistinctSubscriptions(t *testing.T) {
h := NewHub()
a := newTestConn("k.a", []string{"k.a"})
b := newTestConn("k.b", []string{"k.b"})
if ok, _ := h.TryRegisterExclusive(a); !ok {
t.Fatal("register a failed")
}
if ok, _ := h.TryRegisterExclusive(b); !ok {
t.Error("distinct subscription b should register")
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
)
@@ -102,6 +103,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
}
if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil {
return rejErr
}
var cleanup func() error
if ack.FirstForKey && keyDef.PreConsume != nil {
@@ -171,6 +175,17 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
}
// rejectionError converts a rejected hello_ack into a structured precondition
// error; returns nil when the ack is absent or not a rejection.
func rejectionError(ack *protocol.HelloAck, eventKey string) error {
if ack == nil || !ack.Rejected {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"cannot start consumer: %s", ack.RejectReason).
WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey)
}
func truncateDuration(d time.Duration) time.Duration {
return d.Truncate(time.Second)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event/protocol"
)
func TestRejectionError_Rejected(t *testing.T) {
ack := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, Rejected: true, RejectReason: "another consumer (pid 9) is already running"}
err := rejectionError(ack, "im.message.receive_v1")
if err == nil {
t.Fatal("expected error for rejected ack")
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Category != errs.CategoryValidation || prob.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition; err=%q", prob, err.Error())
}
if !strings.Contains(err.Error(), "already running") {
t.Errorf("error = %q, want reject reason", err.Error())
}
}
func TestRejectionError_NotRejected(t *testing.T) {
if err := rejectionError(&protocol.HelloAck{Type: protocol.MsgTypeHelloAck}, "k"); err != nil {
t.Errorf("expected nil for non-rejected ack, got %v", err)
}
if err := rejectionError(nil, "k"); err != nil {
t.Errorf("expected nil for nil ack, got %v", err)
}
}

View File

@@ -53,8 +53,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
if count > 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
"another event bus is already connected to this app (%d remote event connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("remote event connection detected; `lark-cli event status` and `lark-cli event stop` only inspect local buses; stop the owner host/process, wait for the platform connection timeout, or use a separate app/profile")
}
}
} else {

View File

@@ -50,8 +50,16 @@ func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Hint, "event stop") {
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
wantHints := []string{
"remote event connection",
"`lark-cli event status` and `lark-cli event stop` only inspect local buses",
"stop the owner host/process",
"wait for the platform connection timeout",
}
for _, want := range wantHints {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint missing %q\ngot: %q", want, ve.Hint)
}
}
}

View File

@@ -43,9 +43,11 @@ type Hello struct {
}
type HelloAck struct {
Type string `json:"type"`
BusVersion string `json:"bus_version"`
FirstForKey bool `json:"first_for_key"`
Type string `json:"type"`
BusVersion string `json:"bus_version"`
FirstForKey bool `json:"first_for_key"`
Rejected bool `json:"rejected,omitempty"`
RejectReason string `json:"reject_reason,omitempty"`
}
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
@@ -117,6 +119,17 @@ func NewHelloAck(busVersion string, firstForKey bool) *HelloAck {
}
}
// NewHelloAckRejected builds a hello_ack that tells the consumer the bus refused
// registration (e.g. a SingleConsumer EventKey already has a running consumer).
func NewHelloAckRejected(busVersion, reason string) *HelloAck {
return &HelloAck{
Type: MsgTypeHelloAck,
BusVersion: busVersion,
Rejected: true,
RejectReason: reason,
}
}
func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event {
return &Event{
Type: MsgTypeEvent,

View File

@@ -105,3 +105,25 @@ func TestReadFrame_PropagatesEOF(t *testing.T) {
t.Errorf("err = %v, want io.EOF", err)
}
}
func TestHelloAckRejected_RoundTrip(t *testing.T) {
ack := NewHelloAckRejected("v1", "another consumer (pid 42) is already running for this subscription")
if !ack.Rejected || ack.RejectReason == "" {
t.Fatalf("NewHelloAckRejected fields: %+v", ack)
}
var buf bytes.Buffer
if err := Encode(&buf, ack); err != nil {
t.Fatalf("encode: %v", err)
}
msg, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
if err != nil {
t.Fatalf("decode: %v", err)
}
got, ok := msg.(*HelloAck)
if !ok {
t.Fatalf("decoded type = %T, want *HelloAck", msg)
}
if !got.Rejected || got.RejectReason != ack.RejectReason {
t.Errorf("roundtrip = %+v, want Rejected with reason", got)
}
}

View File

@@ -26,6 +26,14 @@ func RegisterKey(def KeyDefinition) {
panic(fmt.Sprintf("EventKey %s: EventType must not be empty", def.Key))
}
if def.SubscriptionType == "" {
def.SubscriptionType = SubTypeEvent
}
if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback {
panic(fmt.Sprintf("EventKey %s: SubscriptionType must be %q or %q; got %q",
def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType))
}
validateSchema(def)
validateParams(def)
validateAuth(def)

View File

@@ -244,3 +244,58 @@ func TestBufferSize_Clamped(t *testing.T) {
t.Errorf("BufferSize = %d, want %d", def.BufferSize, MaxBufferSize)
}
}
func TestRegisterKey_SubscriptionTypeDefaultsToEvent(t *testing.T) {
const key = "test.subtype.default"
RegisterKey(KeyDefinition{
Key: key,
EventType: key,
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer UnregisterKeyForTest(key)
def, ok := Lookup(key)
if !ok {
t.Fatalf("Lookup(%q) failed", key)
}
if def.SubscriptionType != SubTypeEvent {
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeEvent)
}
if def.SingleConsumer {
t.Errorf("SingleConsumer = true, want false (default)")
}
}
func TestRegisterKey_SubscriptionTypeCallbackPreserved(t *testing.T) {
const key = "test.subtype.callback"
RegisterKey(KeyDefinition{
Key: key,
EventType: key,
SubscriptionType: SubTypeCallback,
SingleConsumer: true,
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
defer UnregisterKeyForTest(key)
def, _ := Lookup(key)
if def.SubscriptionType != SubTypeCallback {
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeCallback)
}
if !def.SingleConsumer {
t.Errorf("SingleConsumer = false, want true")
}
}
func TestRegisterKey_InvalidSubscriptionTypePanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic for invalid SubscriptionType")
}
}()
RegisterKey(KeyDefinition{
Key: "test.subtype.bogus",
EventType: "test.subtype.bogus",
SubscriptionType: "bogus",
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
})
}

View File

@@ -42,6 +42,18 @@ const (
ParamInt ParamType = "int"
)
// SubscriptionType marks whether an EventKey is delivered via Lark event
// subscription or interactive callback subscription. It is a sibling of
// EventType (which holds the concrete Lark event_type string).
type SubscriptionType string
const (
// SubTypeEvent: checked against the published app_versions event_infos.
SubTypeEvent SubscriptionType = "event"
// SubTypeCallback: checked against application/get subscribed_callbacks.
SubTypeCallback SubscriptionType = "callback"
)
// ParamValue.Desc is mandatory so AI consumers can decide which value to pick.
type ParamValue struct {
Value string `json:"value"`
@@ -96,6 +108,10 @@ type KeyDefinition struct {
Description string `json:"description,omitempty"`
EventType string `json:"event_type"`
// SubscriptionType selects which console "底账" the precheck reads.
// Empty is normalized to SubTypeEvent at RegisterKey.
SubscriptionType SubscriptionType `json:"subscription_type,omitempty"`
Params []ParamDef `json:"params,omitempty"`
Schema SchemaDef `json:"schema"`
@@ -148,4 +164,8 @@ type KeyDefinition struct {
BufferSize int `json:"buffer_size,omitempty"`
Workers int `json:"workers,omitempty"`
// SingleConsumer rejects a second consumer for the same SubscriptionID at
// the bus handshake. Default false = unlimited consumers (fan-out).
SingleConsumer bool `json:"single_consumer,omitempty"`
}

View File

@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
{"unicode normal", "报告.xlsx", false},
{"dot-dot resolves to cwd", "subdir/..", false},
// ── GIVEN: empty or blank paths → THEN: rejected ──
{"empty path", "", true},
{"blank path", " ", true},
// ── GIVEN: path traversal via .. → THEN: rejected ──
{"dot-dot escape", "../../.ssh/authorized_keys", true},
{"dot-dot mid path", "subdir/../../etc/passwd", true},

View File

@@ -60,6 +60,10 @@ func safePath(raw, flagName string) (string, error) {
return "", err
}
if strings.TrimSpace(raw) == "" {
return "", fmt.Errorf("%s must not be empty", flagName)
}
if isAbsolutePath(raw) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}

View File

@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
{"unicode normal", "报告.xlsx", false},
{"dot-dot resolves to cwd", "subdir/..", false},
// ── GIVEN: empty or blank paths → THEN: rejected ──
{"empty path", "", true},
{"blank path", " ", true},
// ── GIVEN: path traversal via .. → THEN: rejected ──
{"dot-dot escape", "../../.ssh/authorized_keys", true},
{"dot-dot mid path", "subdir/../../etc/passwd", true},

View File

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

View File

@@ -0,0 +1,795 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"fmt"
"io"
"math"
"mime"
"net"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
docCoverResourceType = "cover"
docCoverUploadParent = "docx_image"
docCoverURLMaxBytes = int64(20 * 1024 * 1024)
docCoverDownloadName = "cover"
docCoverURLDownloadName = "cover"
)
type docCoverHTTPStatusCause int
func (c docCoverHTTPStatusCause) Error() string {
return http.StatusText(int(c))
}
type docCoverURLGuardError string
func (e docCoverURLGuardError) Error() string {
return string(e)
}
var docCoverAllowedContentTypes = map[string]string{
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
var DocResourceDownload = common.Shortcut{
Service: "docs",
Command: "resource-download",
Description: "Download a document resource (type=cover downloads the cover image content)",
Risk: "read",
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
{Name: "output", Desc: "local save path", Required: true},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: validateDocCoverType,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
d := common.NewDryRunAPI()
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
d.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
}
d.GET("/open-apis/docx/v1/documents/:document_id").
Desc("Read document cover metadata").
Set("document_id", documentID)
d.GET("/open-apis/drive/v1/medias/:cover_token/download").
Desc("Download cover image content").
Set("cover_token", "<cover.token>").
Set("output", runtime.Str("output"))
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
if err != nil {
return err
}
outputPath := runtime.Str("output")
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
cover, err := getDocCover(runtime, documentID)
if err != nil {
return err
}
if cover.Token == "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "document has no cover (cover is empty): %s", common.MaskToken(documentID)).WithParam("--type")
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover: %s\n", common.MaskToken(cover.Token))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(cover.Token)),
})
if err != nil {
return wrapDocNetworkErr(err, "download cover failed: %v", err)
}
defer resp.Body.Close()
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
}
if !runtime.Bool("overwrite") {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
}
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
}
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"saved_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
"cover": cover.toOutput(),
}, nil)
return nil
},
}
var DocResourceUpdate = common.Shortcut{
Service: "docs",
Command: "resource-update",
Description: "Upload and update a document resource (type=cover)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
{Name: "file", Desc: "local image file path (files > 20MB use multipart upload automatically)"},
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file"},
{Name: "url", Desc: "HTTPS image URL to download and upload"},
{Name: "offset-ratio-x", Type: "float64", Desc: "cover horizontal offset ratio"},
{Name: "offset-ratio-y", Type: "float64", Desc: "cover vertical offset ratio"},
},
Validate: validateDocCoverUpdate,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
d := common.NewDryRunAPI()
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
d.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
}
source := docCoverDryRunSource(runtime)
d.Desc("upload cover image and update document cover").
POST("/open-apis/drive/v1/medias/upload_all").
Desc("Upload cover image").
Body(map[string]interface{}{
"file": source,
"file_name": "<cover_file_name>",
"parent_type": docCoverUploadParent,
"parent_node": documentID,
"extra": fmt.Sprintf(`{"drive_route_token":"%s"}`, documentID),
})
d.PATCH("/open-apis/docx/v1/documents/:document_id").
Desc("Update document cover").
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": buildDocCoverUpdateBody("<file_token>", runtime)}})
d.Set("document_id", documentID)
if runtime.Str("url") != "" {
d.Set("url_safety", "HTTPS only; private/loopback/link-local IPs rejected; max 3 redirects; image content-types only; max 20MiB")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
if err != nil {
return err
}
source, err := readDocCoverUpdateSource(ctx, runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploading cover image: %s (%d bytes)\n", source.FileName, source.FileSize)
if source.FileSize > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
uploadCfg := UploadDocMediaFileConfig{
FilePath: source.FilePath,
Reader: source.Reader,
FileName: source.FileName,
FileSize: source.FileSize,
ParentType: docCoverUploadParent,
ParentNode: documentID,
DocID: documentID,
}
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", common.MaskToken(fileToken))
coverBody := buildDocCoverUpdateBody(fileToken, runtime)
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
nil,
map[string]interface{}{"update_cover": map[string]interface{}{"cover": coverBody}},
); err != nil {
return err
}
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"updated": true,
"source": source.Kind,
"file_token": fileToken,
"cover": coverBody,
}, nil)
return nil
},
}
var DocResourceDelete = common.Shortcut{
Service: "docs",
Command: "resource-delete",
Description: "Delete a document resource (type=cover is idempotent when empty)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
},
Validate: validateDocCoverType,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
documentID := docRef.Token
d := common.NewDryRunAPI()
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
d.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
}
d.GET("/open-apis/docx/v1/documents/:document_id").
Desc("Read document cover metadata for idempotency").
Set("document_id", documentID)
d.PATCH("/open-apis/docx/v1/documents/:document_id").
Desc("Clear document cover when one exists").
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}})
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
if err != nil {
return err
}
cover, err := getDocCover(runtime, documentID)
if err != nil {
return err
}
if cover.Token == "" {
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"deleted": false,
"already_empty": true,
}, nil)
return nil
}
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
nil,
map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}},
); err != nil {
return err
}
runtime.Out(map[string]interface{}{
"document_id": documentID,
"type": docCoverResourceType,
"deleted": true,
"already_empty": false,
"previous_cover": cover.toOutput(),
}, nil)
return nil
},
}
type docCoverMetadata struct {
Token string
OffsetRatioX *float64
OffsetRatioY *float64
}
func (c docCoverMetadata) toOutput() map[string]interface{} {
out := map[string]interface{}{"token": c.Token}
if c.OffsetRatioX != nil {
out["offset_ratio_x"] = *c.OffsetRatioX
}
if c.OffsetRatioY != nil {
out["offset_ratio_y"] = *c.OffsetRatioY
}
return out
}
type docCoverUpdateSource struct {
Kind string
FilePath string
Reader io.Reader
FileName string
FileSize int64
}
func validateDocCoverType(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("type") != docCoverResourceType {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --type %q, expected cover", runtime.Str("type")).WithParam("--type")
}
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if docRef.Kind == "doc" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
}
return nil
}
func validateDocCoverUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateDocCoverType(ctx, runtime); err != nil {
return err
}
sourceCount := 0
var params []errs.InvalidParam
if runtime.Str("file") != "" {
sourceCount++
params = append(params, errs.InvalidParam{Name: "--file", Reason: "source flag"})
}
if runtime.Bool("from-clipboard") {
sourceCount++
params = append(params, errs.InvalidParam{Name: "--from-clipboard", Reason: "source flag"})
}
if runtime.Str("url") != "" {
sourceCount++
params = append(params, errs.InvalidParam{Name: "--url", Reason: "source flag"})
}
if sourceCount == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file, --from-clipboard or --url is required").WithParams(
errs.InvalidParam{Name: "--file", Reason: "provide one source"},
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide one source"},
errs.InvalidParam{Name: "--url", Reason: "provide one source"},
)
}
if sourceCount > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file, --from-clipboard and --url are mutually exclusive").WithParams(params...)
}
if err := validateCoverOffset(runtime, "offset-ratio-x"); err != nil {
return err
}
if err := validateCoverOffset(runtime, "offset-ratio-y"); err != nil {
return err
}
if rawURL := runtime.Str("url"); rawURL != "" {
if _, err := parseDocCoverURLSyntax(rawURL); err != nil {
return err
}
}
return nil
}
func validateCoverOffset(runtime *common.RuntimeContext, name string) error {
if !runtime.Changed(name) {
return nil
}
value := runtime.Float64(name)
if math.IsNaN(value) || math.IsInf(value, 0) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a finite number", name).WithParam("--" + name)
}
return nil
}
func resolveDocxDocumentIDForResource(runtime *common.RuntimeContext, input string) (string, error) {
docRef, err := parseDocumentRef(input)
if err != nil {
return "", err
}
switch docRef.Kind {
case "docx":
return docRef.Token, nil
case "wiki":
return resolveDocxDocumentID(runtime, input)
case "doc":
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents").WithParam("--doc")
}
}
func getDocCover(runtime *common.RuntimeContext, documentID string) (docCoverMetadata, error) {
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
nil, nil)
if err != nil {
return docCoverMetadata{}, err
}
coverData := common.GetMap(data, "document", "cover")
if len(coverData) == 0 {
coverData = common.GetMap(data, "cover")
}
cover := docCoverMetadata{Token: common.GetString(coverData, "token")}
if value, ok := getOptionalFloat(coverData, "offset_ratio_x"); ok {
cover.OffsetRatioX = &value
}
if value, ok := getOptionalFloat(coverData, "offset_ratio_y"); ok {
cover.OffsetRatioY = &value
}
return cover, nil
}
func getOptionalFloat(m map[string]interface{}, key string) (float64, bool) {
if m == nil {
return 0, false
}
switch v := m[key].(type) {
case float64:
return v, true
case int:
return float64(v), true
case int64:
return float64(v), true
}
return 0, false
}
func readDocCoverUpdateSource(ctx context.Context, runtime *common.RuntimeContext) (docCoverUpdateSource, error) {
if runtime.Bool("from-clipboard") {
fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n")
content, err := readClipboardImage()
if err != nil {
return docCoverUpdateSource{}, err
}
return docCoverUpdateSource{
Kind: "clipboard",
Reader: bytes.NewReader(content),
FileName: "clipboard.png",
FileSize: int64(len(content)),
}, nil
}
if rawURL := runtime.Str("url"); rawURL != "" {
content, fileName, err := downloadDocCoverURL(ctx, runtime, rawURL)
if err != nil {
return docCoverUpdateSource{}, err
}
return docCoverUpdateSource{
Kind: "url",
Reader: bytes.NewReader(content),
FileName: fileName,
FileSize: int64(len(content)),
}, nil
}
filePath := runtime.Str("file")
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return docCoverUpdateSource{}, wrapDocInputFileErr(err, "file not found")
}
if !stat.Mode().IsRegular() {
return docCoverUpdateSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
return docCoverUpdateSource{
Kind: "file",
FilePath: filePath,
FileName: filepath.Base(filePath),
FileSize: stat.Size(),
}, nil
}
func buildDocCoverUpdateBody(fileToken string, runtime *common.RuntimeContext) map[string]interface{} {
cover := map[string]interface{}{"token": fileToken}
if runtime.Changed("offset-ratio-x") {
cover["offset_ratio_x"] = runtime.Float64("offset-ratio-x")
}
if runtime.Changed("offset-ratio-y") {
cover["offset_ratio_y"] = runtime.Float64("offset-ratio-y")
}
return cover
}
func docCoverDryRunSource(runtime *common.RuntimeContext) string {
if runtime.Bool("from-clipboard") {
return "<clipboard image>"
}
if rawURL := runtime.Str("url"); rawURL != "" {
return rawURL
}
if filePath := runtime.Str("file"); filePath != "" {
return "@" + filePath
}
return "<cover image>"
}
func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, raw string) ([]byte, string, error) {
u, err := parseAndValidateDocCoverURL(ctx, raw)
if err != nil {
return nil, "", err
}
baseClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
}
client := newDocCoverHTTPClient(baseClient)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) //nolint:forbidigo // cover --url fetches external user content; RuntimeContext API helpers are Lark-API only.
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
}
resp, err := client.Do(req) //nolint:forbidigo // cover --url uses a guarded external downloader, not Lark API transport.
if err != nil {
return nil, "", wrapDocNetworkErr(err, "download cover URL failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
subtype := errs.SubtypeNetworkTransport
if resp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
cause := docCoverHTTPStatusCause(resp.StatusCode)
return nil, "", errs.NewNetworkError(subtype, "download cover URL failed: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode).WithCause(cause)
}
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil || mediaType == "" {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response must include an image Content-Type").WithParam("--url")
}
mediaType = strings.ToLower(mediaType)
ext, ok := docCoverAllowedContentTypes[mediaType]
if !ok {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/png, image/jpeg, image/gif or image/webp", mediaType).WithParam("--url")
}
limited := io.LimitReader(resp.Body, docCoverURLMaxBytes+1)
content, err := io.ReadAll(limited)
if err != nil {
return nil, "", wrapDocNetworkErr(err, "read cover URL response failed: %v", err)
}
if int64(len(content)) > docCoverURLMaxBytes {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response exceeds 20MiB limit").WithParam("--url")
}
fileName := docCoverURLFileName(resp.Request.URL, ext)
return content, fileName, nil
}
func parseAndValidateDocCoverURL(ctx context.Context, raw string) (*url.URL, error) {
u, err := parseDocCoverURLSyntax(raw)
if err != nil {
return nil, err
}
if err := validateDocCoverURLHost(ctx, u.Hostname()); err != nil {
return nil, err
}
return u, nil
}
func parseDocCoverURLSyntax(raw string) (*url.URL, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
}
if u.Scheme != "https" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must use https").WithParam("--url")
}
if u.User != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not include userinfo").WithParam("--url")
}
host := u.Hostname()
if host == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
}
return u, nil
}
func docCoverURLFileName(u *url.URL, ext string) string {
base := path.Base(u.EscapedPath())
if base == "." || base == "/" || base == "" {
return docCoverURLDownloadName + ext
}
unescaped, err := url.PathUnescape(base)
if err == nil {
base = unescaped
}
base = filepath.Base(base)
if strings.TrimSpace(base) == "" || base == "." || base == string(filepath.Separator) {
return docCoverURLDownloadName + ext
}
if filepath.Ext(base) == "" {
base += ext
}
return base
}
func validateDocCoverURLHost(ctx context.Context, host string) error {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
}
if ip := net.ParseIP(host); ip != nil {
if isUnsafeDocCoverIP(ip) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
}
return nil
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: %v", err).WithParam("--url").WithCause(err)
}
if len(ips) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: no addresses").WithParam("--url")
}
for _, ip := range ips {
if isUnsafeDocCoverIP(ip) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
}
}
return nil
}
func isUnsafeDocCoverIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 10 || v4[0] == 127 {
return true
}
if v4[0] == 169 && v4[1] == 254 {
return true
}
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
return true
}
if v4[0] == 192 && v4[1] == 168 {
return true
}
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
return true
}
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) {
return true
}
if v4[0] >= 240 {
return true
}
return false
}
return ip.IsPrivate()
}
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
if base == nil {
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
}
cloned := *base
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
}
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
if len(via) >= 3 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
}
if len(via) > 0 {
prev := via[len(via)-1]
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirect from https to http is not allowed").WithParam("--url")
}
}
_, err := parseAndValidateDocCoverURL(req.Context(), req.URL.String())
return err
}
return &cloned
}
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
var cloned *http.Transport
if src, ok := base.(*http.Transport); ok && src != nil {
cloned = src.Clone()
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
cloned = def.Clone()
} else {
cloned = &http.Transport{}
}
cloned.Proxy = nil
origDial := cloned.DialContext
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
if err != nil {
return nil, err
}
if err := validateDocCoverConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
if cloned.DialTLSContext != nil {
origDialTLS := cloned.DialTLSContext
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
if err != nil {
return nil, err
}
if err := validateDocCoverConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
}
return cloned
}
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
if dialFn != nil {
return dialFn(ctx, network, addr)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
func validateDocCoverConnRemoteIP(conn net.Conn) error {
if conn == nil {
return docCoverURLGuardError("nil connection")
}
addr := conn.RemoteAddr()
if addr == nil {
return docCoverURLGuardError("missing remote address")
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
host = addr.String()
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return docCoverURLGuardError("invalid remote IP")
}
if isUnsafeDocCoverIP(ip) {
return docCoverURLGuardError("local/internal host is not allowed")
}
return nil
}

View File

@@ -0,0 +1,712 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-download-app"))
documentID := "doxcnCoverDownload1"
coverToken := "cover_token_download_123"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{
"token": coverToken,
"offset_ratio_x": 0.25,
"offset_ratio_y": 0.75,
}))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/" + coverToken + "/download",
Status: 200,
Body: []byte("png-data"),
Headers: http.Header{
"Content-Type": []string{"image/png"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeDocResourceOutput(t, stdout)
data := out.Data
if data["type"] != "cover" {
t.Fatalf("type = %v, want cover", data["type"])
}
if data["content_type"] != "image/png" {
t.Fatalf("content_type = %v, want image/png", data["content_type"])
}
if int(data["size_bytes"].(float64)) != len("png-data") {
t.Fatalf("size_bytes = %v", data["size_bytes"])
}
savedPath, _ := data["saved_path"].(string)
if !strings.HasSuffix(savedPath, "cover.png") {
t.Fatalf("saved_path = %q, want cover.png suffix", savedPath)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "cover.png"))
if err != nil {
t.Fatalf("ReadFile(cover.png) error: %v", err)
}
if string(content) != "png-data" {
t.Fatalf("downloaded content = %q", string(content))
}
cover := data["cover"].(map[string]interface{})
if cover["token"] != coverToken {
t.Fatalf("cover.token = %v, want %s", cover["token"], coverToken)
}
}
func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-download-app"))
documentID := "doxcnCoverEmptyDownload1"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover.png",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected empty cover error, got nil")
}
assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--type")
if _, statErr := os.Stat(filepath.Join(tmpDir, "cover.png")); !os.IsNotExist(statErr) {
t.Fatalf("cover.png should not be created, statErr=%v", statErr)
}
}
func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-delete-app"))
documentID := "doxcnCoverEmptyDelete1"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
err := mountAndRunDocs(t, DocResourceDelete, []string{
"resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocResourceOutput(t, stdout).Data
if data["deleted"] != false {
t.Fatalf("deleted = %v, want false", data["deleted"])
}
if data["already_empty"] != true {
t.Fatalf("already_empty = %v, want true", data["already_empty"])
}
}
func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-delete-app"))
documentID := "doxcnCoverDelete1"
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{"token": "cover_token_delete_123"}))
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + documentID,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceDelete, []string{
"resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := string(patchStub.CapturedBody)
if !strings.Contains(body, `"update_cover"`) || !strings.Contains(body, `"cover":null`) {
t.Fatalf("PATCH body = %s, want update_cover.cover=null", body)
}
data := decodeDocResourceOutput(t, stdout).Data
if data["deleted"] != true {
t.Fatalf("deleted = %v, want true", data["deleted"])
}
if data["already_empty"] != false {
t.Fatalf("already_empty = %v, want false", data["already_empty"])
}
}
func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-update-app"))
documentID := "doxcnCoverUpdate1"
fileToken := "file_cover_uploaded_token_12345"
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
if err := os.WriteFile("cover.png", []byte("png-data"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": fileToken},
},
}
reg.Register(uploadStub)
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + documentID,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", documentID,
"--type", "cover",
"--file", "cover.png",
"--offset-ratio-x", "0.2",
"--offset-ratio-y", "0.8",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v — stderr: %s", err, stderr.String())
}
if !bytes.Contains(uploadStub.CapturedBody, []byte("png-data")) {
t.Fatalf("upload body does not contain file bytes")
}
uploadBody := string(uploadStub.CapturedBody)
if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "docx_image") {
t.Fatalf("upload body missing docx_image parent type: %s", uploadBody)
}
if !strings.Contains(uploadBody, "drive_route_token") || !strings.Contains(uploadBody, documentID) {
t.Fatalf("upload body missing drive_route_token extra: %s", uploadBody)
}
patchBody := string(patchStub.CapturedBody)
for _, want := range []string{`"update_cover"`, `"token":"` + fileToken + `"`, `"offset_ratio_x":0.2`, `"offset_ratio_y":0.8`} {
if !strings.Contains(patchBody, want) {
t.Fatalf("PATCH body = %s, missing %s", patchBody, want)
}
}
if strings.Contains(stderr.String(), fileToken) {
t.Fatalf("stderr leaked full file_token: %s", stderr.String())
}
data := decodeDocResourceOutput(t, stdout).Data
if data["file_token"] != fileToken {
t.Fatalf("stdout file_token = %v, want %s", data["file_token"], fileToken)
}
cover := data["cover"].(map[string]interface{})
if cover["token"] != fileToken {
t.Fatalf("stdout cover.token = %v, want %s", cover["token"], fileToken)
}
}
func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", "doxcnCoverValidate1",
"--type", "cover",
"--file", "cover.png",
"--from-clipboard",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected mutual exclusion error, got nil")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard")
}
func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", "doxcnCoverValidateRequired1",
"--type", "cover",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected missing source error, got nil")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard", "--url")
}
func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"--doc", "doxcnCoverURLValidate1",
"--type", "cover",
"--url", "https://127.0.0.1/cover.png",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected unsafe URL error, got nil")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestDocCoverURLSyntaxValidation(t *testing.T) {
cases := []struct {
name string
raw string
ok bool
}{
{name: "https", raw: " https://example.com/cover.png ", ok: true},
{name: "http", raw: "http://example.com/cover.png"},
{name: "userinfo", raw: "https://user:pass@example.com/cover.png"},
{name: "empty host", raw: "https:///cover.png"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
u, err := parseDocCoverURLSyntax(tc.raw)
if tc.ok {
if err != nil {
t.Fatalf("parseDocCoverURLSyntax() error: %v", err)
}
if u.String() != "https://example.com/cover.png" {
t.Fatalf("URL = %q, want normalized https URL", u.String())
}
return
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
})
}
}
func TestDocResourceCoverDryRunsPlanAPIs(t *testing.T) {
downloadRT := docValidateRuntime(t, map[string]string{
"doc": "doxcnCoverDryRunDownload",
"output": "cover",
}, nil, nil)
download := decodeDocDryRun(t, DocResourceDownload.DryRun(context.Background(), downloadRT))
if len(download.API) != 2 {
t.Fatalf("download dry-run API count = %d, want 2", len(download.API))
}
if got := download.API[0].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDownload" {
t.Fatalf("download metadata URL = %q", got)
}
if got := download.API[1].URL; got != "/open-apis/drive/v1/medias/%3Ccover.token%3E/download" {
t.Fatalf("download media URL = %q", got)
}
updateRT := docValidateRuntime(t, map[string]string{
"doc": "https://example.larksuite.com/wiki/wikcnCoverDryRunUpdate",
"url": "https://example.com/cover.png",
}, nil, nil)
update := decodeDocDryRun(t, DocResourceUpdate.DryRun(context.Background(), updateRT))
if len(update.API) != 3 {
t.Fatalf("update dry-run API count = %d, want 3", len(update.API))
}
if got := update.API[0].URL; got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("wiki resolve URL = %q", got)
}
if got := update.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("upload URL = %q", got)
}
if got := update.API[2].URL; got != "/open-apis/docx/v1/documents/%3Cresolved_docx_token%3E" {
t.Fatalf("patch URL = %q", got)
}
if got := update.API[1].Body["file"]; got != "https://example.com/cover.png" {
t.Fatalf("upload source = %#v", got)
}
deleteRT := docValidateRuntime(t, map[string]string{"doc": "doxcnCoverDryRunDelete"}, nil, nil)
deleteDry := decodeDocDryRun(t, DocResourceDelete.DryRun(context.Background(), deleteRT))
if len(deleteDry.API) != 2 {
t.Fatalf("delete dry-run API count = %d, want 2", len(deleteDry.API))
}
if got := deleteDry.API[1].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDelete" {
t.Fatalf("delete patch URL = %q", got)
}
}
func TestDocResourceCoverDryRunReportsInvalidDoc(t *testing.T) {
rt := docValidateRuntime(t, map[string]string{"doc": "https://example.com/sheets/shtxxx"}, nil, nil)
dry := DocResourceDownload.DryRun(context.Background(), rt)
if got := dry.Format(); !strings.Contains(got, "error:") {
t.Fatalf("dry-run error output = %q, want error field", got)
}
}
func TestParseAndValidateDocCoverURLRejectsUnsafeIP(t *testing.T) {
_, err := parseAndValidateDocCoverURL(context.Background(), "https://127.0.0.1/cover.png")
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestValidateDocCoverURLHost(t *testing.T) {
for _, host := range []string{"", "localhost", "service.localhost", "127.0.0.1"} {
t.Run(host, func(t *testing.T) {
assertValidationContract(t, validateDocCoverURLHost(context.Background(), host), errs.SubtypeInvalidArgument, "--url")
})
}
if err := validateDocCoverURLHost(context.Background(), "1.1.1.1"); err != nil {
t.Fatalf("validateDocCoverURLHost(public IP) error: %v", err)
}
}
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
for _, rawIP := range []string{
"10.0.0.1",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"192.168.0.1",
"100.64.0.1",
"198.18.0.1",
"240.0.0.1",
} {
t.Run(rawIP, func(t *testing.T) {
if !isUnsafeDocCoverIP(net.ParseIP(rawIP)) {
t.Fatalf("%s was classified as safe", rawIP)
}
})
}
if isUnsafeDocCoverIP(net.ParseIP("1.1.1.1")) {
t.Fatal("public IPv4 address was classified as unsafe")
}
}
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
baseClient := &http.Client{Transport: baseTransport}
client := newDocCoverHTTPClient(baseClient)
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
}
if transport.Proxy != nil {
t.Fatal("cover URL downloader must not inherit proxy settings")
}
if baseTransport.Proxy == nil {
t.Fatal("base transport proxy was mutated")
}
}
func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
client := newDocCoverHTTPClient(&http.Client{})
req, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/cover.png", nil)
if err != nil {
t.Fatalf("NewRequest() error: %v", err)
}
if err := client.CheckRedirect(req, []*http.Request{{}, {}, {}}); err == nil {
t.Fatal("expected too many redirects error")
}
prev, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/start", nil)
if err != nil {
t.Fatalf("NewRequest(prev) error: %v", err)
}
downgrade, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/cover.png", nil)
if err != nil {
t.Fatalf("NewRequest(downgrade) error: %v", err)
}
if err := client.CheckRedirect(downgrade, []*http.Request{prev}); err == nil {
t.Fatal("expected https-to-http redirect error")
}
}
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
if err := validateDocCoverConnRemoteIP(nil); err == nil {
t.Fatal("expected nil connection error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
t.Fatal("expected missing remote address error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
t.Fatal("expected invalid remote IP error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
t.Fatal("expected local remote IP error")
}
}
func TestDocCoverURLFileName(t *testing.T) {
cases := []struct {
raw string
ext string
want string
}{
{raw: "https://example.com/images/cover", ext: ".png", want: "cover.png"},
{raw: "https://example.com/images/cover.jpeg", ext: ".png", want: "cover.jpeg"},
{raw: "https://example.com/", ext: ".webp", want: "cover.webp"},
{raw: "https://example.com/images/%2Fescaped", ext: ".gif", want: "escaped.gif"},
}
for _, tc := range cases {
t.Run(tc.want, func(t *testing.T) {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatalf("url.Parse(%q): %v", tc.raw, err)
}
if got := docCoverURLFileName(u, tc.ext); got != tc.want {
t.Fatalf("docCoverURLFileName() = %q, want %q", got, tc.want)
}
})
}
}
func TestDownloadDocCoverURLSuccess(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("png-data"))
}))
content, fileName, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
if err != nil {
t.Fatalf("downloadDocCoverURL() error: %v", err)
}
if string(content) != "png-data" {
t.Fatalf("content = %q, want png-data", string(content))
}
if fileName != "cover.png" {
t.Fatalf("fileName = %q, want cover.png", fileName)
}
}
func TestDownloadDocCoverURLRejectsHTTPStatus(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("unavailable"))
}))
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
if err == nil {
t.Fatal("expected HTTP status error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed problem", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("problem category = %v, want %v", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("problem subtype = %v, want %v", p.Subtype, errs.SubtypeNetworkServer)
}
if p.Code != http.StatusServiceUnavailable {
t.Fatalf("problem code = %v, want %v", p.Code, http.StatusServiceUnavailable)
}
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("error = %T %v, want *errs.NetworkError", err, err)
}
if networkErr.Cause == nil {
t.Fatal("expected preserved underlying cause, got nil")
}
}
func TestDownloadDocCoverURLRejectsUnsupportedContentType(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("not-image"))
}))
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestDownloadDocCoverURLRejectsOversizeResponse(t *testing.T) {
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = io.CopyN(w, repeatByteReader('x'), docCoverURLMaxBytes+1)
}))
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
}
func TestDocCoverMetadataOutputAndOptionalFloats(t *testing.T) {
x := 0.25
y := 1.0
out := docCoverMetadata{
Token: "cover_token",
OffsetRatioX: &x,
OffsetRatioY: &y,
}.toOutput()
if out["token"] != "cover_token" || out["offset_ratio_x"] != x || out["offset_ratio_y"] != y {
t.Fatalf("cover output = %#v", out)
}
data := map[string]interface{}{
"float": float64(1.5),
"int": 2,
"int64": int64(3),
"text": "4",
}
if got, ok := getOptionalFloat(data, "float"); !ok || got != 1.5 {
t.Fatalf("float optional = %v/%v, want 1.5/true", got, ok)
}
if got, ok := getOptionalFloat(data, "int"); !ok || got != 2 {
t.Fatalf("int optional = %v/%v, want 2/true", got, ok)
}
if got, ok := getOptionalFloat(data, "int64"); !ok || got != 3 {
t.Fatalf("int64 optional = %v/%v, want 3/true", got, ok)
}
if _, ok := getOptionalFloat(data, "text"); ok {
t.Fatal("string optional unexpectedly parsed as float")
}
if _, ok := getOptionalFloat(nil, "missing"); ok {
t.Fatal("nil map optional unexpectedly returned a value")
}
}
func TestDocCoverDryRunSource(t *testing.T) {
cases := []struct {
name string
str map[string]string
bools map[string]bool
want string
}{
{name: "clipboard", bools: map[string]bool{"from-clipboard": true}, want: "<clipboard image>"},
{name: "url", str: map[string]string{"url": "https://example.com/cover.png"}, want: "https://example.com/cover.png"},
{name: "file", str: map[string]string{"file": "cover.png"}, want: "@cover.png"},
{name: "empty", want: "<cover image>"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := docValidateRuntime(t, tc.str, tc.bools, nil)
if got := docCoverDryRunSource(rt); got != tc.want {
t.Fatalf("docCoverDryRunSource() = %q, want %q", got, tc.want)
}
})
}
}
func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
got := map[string]bool{}
for _, shortcut := range Shortcuts() {
got[shortcut.Command] = true
}
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
if !got[want] {
t.Fatalf("Shortcuts() missing %s", want)
}
}
}
func newDocCoverURLTestRuntime(t *testing.T, handler http.Handler) (*common.RuntimeContext, string) {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-download-app"))
targetAddr := server.Listener.Addr().String()
f.HttpClient = func() (*http.Client, error) {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, network, targetAddr)
if err != nil {
return nil, err
}
return docCoverRemoteAddrConn{
Conn: conn,
addr: &net.TCPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443},
}, nil
},
},
}, nil
}
return &common.RuntimeContext{Factory: f}, "https://1.1.1.1/assets/cover"
}
type docCoverRemoteAddrConn struct {
net.Conn
addr net.Addr
}
func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
return c.addr
}
type testAddr string
func (a testAddr) Network() string {
return "test"
}
func (a testAddr) String() string {
return string(a)
}
type repeatByteReader byte
func (r repeatByteReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = byte(r)
}
return len(p), nil
}
func docCoverMetadataStub(documentID string, cover map[string]interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + documentID,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"cover": cover,
},
},
},
}
}
type docResourceOutput struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutput {
t.Helper()
var out docResourceOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode resource output: %v; output=%s", err, stdout.String())
}
return out
}

View File

@@ -19,6 +19,7 @@ func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
@@ -38,9 +39,6 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
if err := validateFetchDetail(runtime); err != nil {
return err
}
if err := validateReadModeFlags(runtime); err != nil {
@@ -71,6 +69,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
@@ -89,8 +90,11 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
}
if lang := resolveFetchLang(runtime); lang != "" {
body["lang"] = lang
}
detail := runtime.Str("detail")
detail := effectiveFetchDetail(runtime)
switch detail {
case "", "simple":
body["export_option"] = map[string]interface{}{
@@ -118,6 +122,16 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
func resolveFetchLang(runtime *common.RuntimeContext) string {
if runtime.Changed("lang") {
return strings.TrimSpace(runtime.Str("lang"))
}
if runtime.Config == nil {
return ""
}
return strings.TrimSpace(string(runtime.Config.Lang))
}
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))
@@ -146,17 +160,33 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
return ro
}
// validateFetchDetail 非 xml 格式markdown不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
// effectiveFetchDetail degrades detail options that cannot be represented by
// non-XML exports. The original flag value is left intact so callers can still
// surface an explicit warning in execute output.
func effectiveFetchDetail(runtime *common.RuntimeContext) string {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return nil
return detail
}
if detail == "with-ids" || detail == "full" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
return "simple"
}
return nil
return detail
}
func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[string]interface{}) string {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return ""
}
if detail != "with-ids" && detail != "full" {
return ""
}
warning := fmt.Sprintf("--detail %s is only supported with --doc-format xml; returning %s output and ignoring the unsupported detail option", detail, format)
appendDocWarning(data, warning)
return warning
}
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。

View File

@@ -5,9 +5,13 @@ package doc
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -59,6 +63,47 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
}
}
func TestBuildFetchBodyIncludesExplicitLang(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
if err := runtime.Cmd.Flags().Set("lang", "en-US"); err != nil {
t.Fatalf("set lang: %v", err)
}
body := buildFetchBody(runtime)
if got := body["lang"]; got != "en-US" {
t.Fatalf("lang = %#v, want %q", got, "en-US")
}
}
func TestBuildFetchBodyUsesRuntimeConfigLang(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
body := buildFetchBody(runtime)
if got := body["lang"]; got != "zh_cn" {
t.Fatalf("lang = %#v, want %q", got, "zh_cn")
}
}
func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
if err := runtime.Cmd.Flags().Set("lang", ""); err != nil {
t.Fatalf("set lang: %v", err)
}
body := buildFetchBody(runtime)
if _, ok := body["lang"]; ok {
t.Fatalf("did not expect blank explicit lang in fetch body: %#v", body)
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -96,6 +141,126 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()
for _, detail := range []string{"with-ids", "full"} {
t.Run(detail, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": "markdown",
"detail": detail,
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
if exportOption == nil {
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
}
if got := exportOption["export_block_id"]; got != false {
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_style_attrs"]; got != false {
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_cite_extra_data"]; got != false {
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
}
})
}
}
func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchWarning",
"revision_id": float64(1),
"content": "# hello",
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchWarning",
"--doc-format", "markdown",
"--detail", "with-ids",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
warnings, _ := data["warnings"].([]interface{})
if len(warnings) != 1 {
t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"])
}
if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") {
t.Fatalf("unexpected warning: %q", got)
}
}
func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchPrettyWarning",
"revision_id": float64(1),
"content": "# hello",
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchPrettyWarning",
"--doc-format", "markdown",
"--detail", "full",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != "# hello\n" {
t.Fatalf("stdout = %q, want markdown content only", got)
}
if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") ||
!strings.Contains(got, "returning markdown output") ||
!strings.Contains(got, "ignoring the unsupported detail option") {
t.Fatalf("stderr missing downgrade warning: %q", got)
}
}
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
@@ -139,6 +304,7 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().String("lang", "", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
@@ -158,6 +324,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().String("lang", "", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")

View File

@@ -91,3 +91,22 @@ func buildDriveRouteExtra(docID string) (string, error) {
}
return string(extra), nil
}
func appendDocWarning(data map[string]interface{}, warning string) {
if data == nil {
return
}
if strings.TrimSpace(warning) == "" {
return
}
switch existing := data["warnings"].(type) {
case []interface{}:
data["warnings"] = append(existing, warning)
case []string:
data["warnings"] = append(existing, warning)
case nil:
data["warnings"] = []string{warning}
default:
data["warnings"] = []interface{}{existing, warning}
}
}

View File

@@ -4,6 +4,7 @@
package doc
import (
"reflect"
"strings"
"testing"
)
@@ -88,3 +89,51 @@ func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) {
t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want)
}
}
func TestAppendDocWarning(t *testing.T) {
t.Parallel()
appendDocWarning(nil, "ignored")
empty := map[string]interface{}{}
appendDocWarning(empty, " ")
if _, ok := empty["warnings"]; ok {
t.Fatalf("blank warning should be ignored: %#v", empty)
}
tests := []struct {
name string
data map[string]interface{}
want interface{}
}{
{
name: "missing warnings",
data: map[string]interface{}{},
want: []string{"new warning"},
},
{
name: "string slice warnings",
data: map[string]interface{}{"warnings": []string{"old warning"}},
want: []string{"old warning", "new warning"},
},
{
name: "interface slice warnings",
data: map[string]interface{}{"warnings": []interface{}{"old warning"}},
want: []interface{}{"old warning", "new warning"},
},
{
name: "scalar warning",
data: map[string]interface{}{"warnings": "old warning"},
want: []interface{}{"old warning", "new warning"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
appendDocWarning(tt.data, "new warning")
if got := tt.data["warnings"]; !reflect.DeepEqual(got, tt.want) {
t.Fatalf("warnings = %#v, want %#v", got, tt.want)
}
})
}
}

View File

@@ -60,6 +60,9 @@ func Shortcuts() []common.Shortcut {
DocMediaUpload,
DocMediaPreview,
DocMediaDownload,
DocResourceDownload,
DocResourceUpdate,
DocResourceDelete,
}
}

View File

@@ -34,301 +34,242 @@ var DriveExport = common.Shortcut{
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return ValidateExport(exportParamsFromFlags(runtime))
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
Token string
DocType string
FileExtension string
SubID string
OutputDir string
FileName string
Overwrite bool
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// ValidateExport runs the CLI-level export constraint checks.
func ValidateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
return err
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
return nil
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
return err
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
}

View File

@@ -34,6 +34,7 @@ type driveExportSpec struct {
DocType string
FileExtension string
SubID string
OnlySchema bool
}
// driveExportTaskResultCommand prints the resume command shown when bounded
@@ -150,6 +151,10 @@ func validateDriveExportSpec(spec driveExportSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
}
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
}
@@ -185,6 +190,9 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {

View File

@@ -51,6 +51,15 @@ func TestValidateDriveExportSpec(t *testing.T) {
name: "base bitable ok",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
},
{
name: "base bitable only schema ok",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base", OnlySchema: true},
},
{
name: "only schema non base rejected",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "xlsx", OnlySchema: true},
wantErr: "--only-schema is only used",
},
{
name: "slides pptx ok",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
@@ -488,72 +497,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -678,6 +621,7 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
"--token", "bitable123",
"--doc-type", "bitable",
"--file-extension", "base",
"--only-schema",
"--as", "bot",
}, f, stdout)
if err != nil {
@@ -694,6 +638,9 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
if createBody["type"] != "bitable" {
t.Fatalf("export_tasks body type = %v, want %q", createBody["type"], "bitable")
}
if createBody["only_schema"] != true {
t.Fatalf("export_tasks body only_schema = %v, want true", createBody["only_schema"])
}
data, err := os.ReadFile(filepath.Join(tmpDir, "crm.base"))
if err != nil {

View File

@@ -25,7 +25,8 @@ var DriveImport = common.Shortcut{
"docs:document.media:upload",
"docs:document:import",
},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
@@ -34,160 +35,132 @@ var DriveImport = common.Shortcut{
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return ValidateImport(importParamsFromFlags(runtime))
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
},
}
// ImportParams holds the user-facing inputs for an import flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
// the drive import implementation without taking a dependency on a --type flag.
type ImportParams struct {
File string
DocType string
FolderToken string
Name string
TargetToken string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
}
}
// importParamsFromFlags reads the standard drive +import flag set.
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
return ImportParams{
File: runtime.Str("file"),
DocType: runtime.Str("type"),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
}
// ValidateImport runs the CLI-level compatibility checks for an import.
func ValidateImport(p ImportParams) error {
return validateDriveImportSpec(p.spec())
}
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
// an import without performing any network or file I/O beyond a local stat.
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
spec := p.spec()
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
}
// RunImport executes the full import flow: upload media -> create import task ->
// bounded poll, then writes the result envelope to the runtime output. It is
// the shared core behind both drive +import and sheets +workbook-import.
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
spec := p.spec()
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.

View File

@@ -257,6 +257,46 @@ func validateDriveImportSpec(spec driveImportSpec) error {
return nil
}
func appendDriveImportFolderTokenWikiCheckDryRun(dry *common.DryRunAPI, spec driveImportSpec) {
folderToken := strings.TrimSpace(spec.FolderToken)
if folderToken == "" {
return
}
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[0] Validate whether --folder-token is a wiki node").
Params(map[string]interface{}{"token": folderToken})
}
func rejectDriveImportWikiFolderToken(runtime *common.RuntimeContext, folderToken string) error {
folderToken = strings.TrimSpace(folderToken)
if folderToken == "" {
return nil
}
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": folderToken},
nil,
)
if err == nil {
node := common.GetMap(data, "node")
if len(node) == 0 {
return nil
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node",
).
WithParam("--folder-token").
WithHint("Pass a Drive folder token, or omit --folder-token to import into the Drive root folder. Wiki node tokens are not accepted as import mount folders.")
}
return nil
}
// driveImportStatus captures the backend fields needed to decide whether the
// import can be surfaced immediately or requires a follow-up poll.
type driveImportStatus struct {

View File

@@ -5,10 +5,12 @@ package drive
import (
"bytes"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -276,6 +278,134 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}
func TestDriveImportRejectsWikiFolderToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"node_token": "wikcnImportTarget",
"obj_type": "docx",
"obj_token": "docxImportTarget",
"title": "Wiki Import Target",
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "wikcnImportTarget",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected wiki folder-token validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != "--folder-token" {
t.Fatalf("param = %q, want --folder-token", validationErr.Param)
}
wantMessage := "--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node"
if validationErr.Message != wantMessage {
t.Fatalf("message = %q, want %q", validationErr.Message, wantMessage)
}
for _, disallowed := range []string{"node_token=", "obj_type=", "Wiki Import Target"} {
if strings.Contains(validationErr.Message, disallowed) {
t.Fatalf("message = %q, must not contain %q", validationErr.Message, disallowed)
}
}
if !strings.Contains(validationErr.Hint, "Drive folder token") {
t.Fatalf("hint = %q, want Drive folder token guidance", validationErr.Hint)
}
}
func TestDriveImportContinuesWhenFolderTokenDoesNotResolveAsWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 1310001,
"msg": "node not found",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_import_media"},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_import_folder"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import_folder",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "docx",
"job_status": 0,
"token": "docx_imported",
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "fldcnImportTarget",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["token"]; got != "docx_imported" {
t.Fatalf("token = %#v, want docx_imported", got)
}
body := decodeCapturedJSONBody(t, createStub)
point, _ := body["point"].(map[string]interface{})
if got := point["mount_key"]; got != "fldcnImportTarget" {
t.Fatalf("import mount_key = %#v, want fldcnImportTarget", got)
}
}
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -114,16 +114,20 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
}
uploadName, _ := got.API[0].Body["file_name"].(string)
if got.API[0].Body != nil {
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
}
uploadName, _ := got.API[1].Body["file_name"].(string)
if uploadName != "base-import.xlsx" {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}
importName, _ := got.API[1].Body["file_name"].(string)
importName, _ := got.API[2].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}

View File

@@ -20,9 +20,9 @@ import (
)
// driveSearchErrUserNotVisible is the Lark service code returned by
// doc_wiki/search when an open_id referenced in --creator-ids / --sharer-ids
// falls outside the app's user-visibility scope (different from the
// search:docs:read API scope).
// doc_wiki/search when an open_id referenced in an identity filter falls
// outside the app's user-visibility scope (different from the search:docs:read
// API scope).
const driveSearchErrUserNotVisible = 99992351
// open_time has a server-side cap of 3 months per request. Rather than
@@ -79,6 +79,8 @@ var DriveSearch = common.Shortcut{
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
{Name: "created-by-me", Type: "bool", Desc: "restrict to docs originally created by me (uses current user's open_id as original_creator_ids)"},
{Name: "original-creator-ids", Desc: "comma-separated original creator open_ids; mutually exclusive with --created-by-me"},
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
{Name: "edited-until", Desc: "end of [my edited] time window"},
@@ -108,7 +110,7 @@ var DriveSearch = common.Shortcut{
Tips: []string{
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
"Use --created-by-me for \"docs I created\". Use --mine for \"docs I own\" (owner semantic).",
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -164,6 +166,9 @@ type driveSearchSpec struct {
Mine bool
CreatorIDs []string
CreatedByMe bool
OriginalCreatorIDs []string
EditedSince string
EditedUntil string
CommentedSince string
@@ -193,6 +198,9 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
Mine: runtime.Bool("mine"),
CreatorIDs: common.SplitCSV(runtime.Str("creator-ids")),
CreatedByMe: runtime.Bool("created-by-me"),
OriginalCreatorIDs: common.SplitCSV(runtime.Str("original-creator-ids")),
EditedSince: runtime.Str("edited-since"),
EditedUntil: runtime.Str("edited-until"),
CommentedSince: runtime.Str("commented-since"),
@@ -221,12 +229,18 @@ func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.T
if spec.Mine && len(spec.CreatorIDs) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
}
if spec.CreatedByMe && len(spec.OriginalCreatorIDs) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --created-by-me and --original-creator-ids")
}
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
}
if spec.Mine && userOpenID == "" {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine")
}
if spec.CreatedByMe && userOpenID == "" {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--created-by-me requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--created-by-me")
}
if err := validateDocTypes(spec.DocTypes); err != nil {
return nil, nil, err
@@ -256,13 +270,20 @@ func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.T
notices = append(notices, n)
}
// Creator identity.
// Identity filters. creator_ids is owner; original_creator_ids is the
// immutable document creator.
switch {
case spec.Mine:
filter["creator_ids"] = []string{userOpenID}
case len(spec.CreatorIDs) > 0:
filter["creator_ids"] = spec.CreatorIDs
}
switch {
case spec.CreatedByMe:
filter["original_creator_ids"] = []string{userOpenID}
case len(spec.OriginalCreatorIDs) > 0:
filter["original_creator_ids"] = spec.OriginalCreatorIDs
}
// Time dimensions — each fills at most one filter key; hour-aggregated ones
// also contribute notices.
@@ -358,6 +379,11 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
for _, id := range spec.OriginalCreatorIDs {
if _, err := common.ValidateUserIDTyped("--original-creator-ids", id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--original-creator-ids %q: %s", id, err).WithParam("--original-creator-ids")
}
}
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
@@ -635,7 +661,7 @@ func enrichDriveSearchError(err error) error {
if !ok || p.Code != driveSearchErrUserNotVisible {
return err
}
p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
p.Hint = "one or more open_ids in --creator-ids / --original-creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return err
}

View File

@@ -245,9 +245,10 @@ func TestValidateDriveSearchIDs(t *testing.T) {
t.Run("all valid", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
CreatorIDs: []string{"ou_aaa"},
ChatIDs: []string{"oc_xxx"},
SharerIDs: []string{"ou_bbb"},
CreatorIDs: []string{"ou_aaa"},
OriginalCreatorIDs: []string{"ou_ccc"},
ChatIDs: []string{"oc_xxx"},
SharerIDs: []string{"ou_bbb"},
}
if err := validateDriveSearchIDs(spec); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -275,6 +276,24 @@ func TestValidateDriveSearchIDs(t *testing.T) {
}
})
t.Run("bad original creator id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{OriginalCreatorIDs: []string{"u_bad"}})
if err == nil || !strings.Contains(err.Error(), "--original-creator-ids") {
t.Fatalf("expected --original-creator-ids error, got: %v", err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if vErr.Param != "--original-creator-ids" {
t.Fatalf("Param = %q, want --original-creator-ids", vErr.Param)
}
})
t.Run("bad chat id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{ChatIDs: []string{"chat_bad"}})
@@ -727,6 +746,33 @@ func TestBuildDriveSearchRequest(t *testing.T) {
}
})
t.Run("--created-by-me fills original_creator_ids from userOpenID", func(t *testing.T) {
t.Parallel()
req, _, err := buildDriveSearchRequest(driveSearchSpec{CreatedByMe: true}, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
got := req["doc_filter"].(map[string]interface{})["original_creator_ids"].([]string)
if len(got) != 1 || got[0] != userOpenID {
t.Fatalf("expected [userOpenID], got %v", got)
}
})
t.Run("--original-creator-ids fills original_creator_ids", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{OriginalCreatorIDs: []string{"ou_a", "ou_b"}}
req, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
for _, filterKey := range []string{"doc_filter", "wiki_filter"} {
got := req[filterKey].(map[string]interface{})["original_creator_ids"].([]string)
if !reflect.DeepEqual(got, []string{"ou_a", "ou_b"}) {
t.Fatalf("%s: expected explicit original creator ids, got %v", filterKey, got)
}
}
})
t.Run("--mine without userOpenID errors", func(t *testing.T) {
t.Parallel()
_, _, err := buildDriveSearchRequest(driveSearchSpec{Mine: true}, "", now)
@@ -735,6 +781,14 @@ func TestBuildDriveSearchRequest(t *testing.T) {
}
})
t.Run("--created-by-me without userOpenID errors", func(t *testing.T) {
t.Parallel()
_, _, err := buildDriveSearchRequest(driveSearchSpec{CreatedByMe: true}, "", now)
if err == nil || !strings.Contains(err.Error(), "--created-by-me") {
t.Fatalf("expected --created-by-me error, got: %v", err)
}
})
t.Run("--mine + --creator-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{Mine: true, CreatorIDs: []string{"ou_x"}}
@@ -756,6 +810,15 @@ func TestBuildDriveSearchRequest(t *testing.T) {
}
})
t.Run("--created-by-me + --original-creator-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{CreatedByMe: true, OriginalCreatorIDs: []string{"ou_x"}}
_, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err == nil || !strings.Contains(err.Error(), "--created-by-me") {
t.Fatalf("expected exclusion error, got: %v", err)
}
})
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{

View File

@@ -123,6 +123,13 @@ func isHTMLBlockBoundary(n *xhtml.Node) bool {
}
}
// PlainTextFromHTML is the exported wrapper over plainTextFromHTML, so the
// mail package can render an HTML signature as a plain-text fallback when a
// message body is sent in plain-text mode. The conversion logic is unchanged.
func PlainTextFromHTML(raw string) string {
return plainTextFromHTML(raw)
}
// bodyLooksLikeHTML reports whether raw appears to contain HTML markup.
// This is intentionally heuristic: it exists to reject obvious plain-text
// input when a draft's authored body is text/html.

View File

@@ -102,3 +102,10 @@ func TestIsHTMLNonTextTag(t *testing.T) {
})
}
}
func TestPlainTextFromHTMLExported(t *testing.T) {
got := PlainTextFromHTML("<p>Hello world</p>")
if !strings.Contains(got, "Hello world") {
t.Fatalf("PlainTextFromHTML: expected to contain \"Hello world\", got %q", got)
}
}

View File

@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag,
@@ -92,7 +93,7 @@ var MailDraftCreate = common.Shortcut{
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -180,12 +181,22 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(input.Body) == "" {
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
senderEmail := resolveComposeSenderEmail(runtime)
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !input.PlainText)
if err != nil {
return err
}
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments, senderEmail)
if err != nil {
return err
}
@@ -241,13 +252,19 @@ func buildRawEMLForDraftCreate(
mailboxID, templateID string,
templateInlineAttachments []templateInlineRef,
templateSmallAttachments []templateAttachmentRef,
senderEmailHint string,
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
// Initialise lint findings as empty (non-nil) slices so callers can
// surface them through the envelope unconditionally even on the
// plain-text branch.
lintApplied, lintBlocked = emptyLintFindings()
senderEmail := resolveComposeSenderEmail(runtime)
// Use the pre-resolved senderEmail when available (avoids a duplicate
// profile API call when Execute already fetched it for auto-resolve).
senderEmail := senderEmailHint
if senderEmail == "" {
senderEmail = resolveComposeSenderEmail(runtime)
}
if senderEmail == "" {
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
}
@@ -290,7 +307,7 @@ func buildRawEMLForDraftCreate(
var composedHTMLBody string
var composedTextBody string
if input.PlainText {
composedTextBody = input.Body
composedTextBody = injectPlainTextSignature(input.Body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body

View File

@@ -62,7 +62,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -88,7 +88,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
Body: `<p>Hello <b>world</b></p>`,
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -124,7 +124,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
Attach: "./big.txt",
}
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err == nil {
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
}
@@ -145,7 +145,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
}
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err == nil {
t.Fatal("expected error for orphaned --inline CID not referenced in body")
}
@@ -166,7 +166,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
}
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err == nil {
t.Fatal("expected error for missing CID reference")
}
@@ -183,7 +183,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
Body: `<p>Hello</p>`,
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -201,7 +201,7 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
Body: `<p>Hello</p>`,
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -237,7 +237,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -260,7 +260,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -283,7 +283,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
PlainText: true,
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -304,7 +304,7 @@ func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
Body: "<p>Please join us</p>",
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}

View File

@@ -134,7 +134,7 @@ var MailDraftEdit = common.Shortcut{
for i := range patch.Ops {
switch patch.Ops[i].Op {
case "insert_signature":
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail, true, true)
if sigErr != nil {
return sigErr
}

View File

@@ -45,6 +45,7 @@ var MailForward = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
signatureFlag,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -96,7 +97,7 @@ var MailForward = common.Shortcut{
return err
}
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -127,12 +128,7 @@ var MailForward = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -156,6 +152,18 @@ var MailForward = common.Shortcut{
senderEmail = orig.headTo
}
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
// matches the correct usage. The actual image download in resolveSignature is deferred
// to after applyTemplate so the final plainText value (which a template can override
// via IsPlainTextMode) is used for the downloadImages decision.
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
}
// --template-id merge (§5.5 Q1-Q5).
var templateLargeAttachmentIDs []string
var templateInlineAttachments []templateInlineRef
@@ -198,6 +206,14 @@ var MailForward = common.Shortcut{
"bccs_count": countAddresses(bccFlag),
})
}
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
// override from the template. This avoids downloading HTML signature images when the
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if sigErr != nil {
return sigErr
}
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
// Post-merge recipient check for --confirm-send + --template-id:
@@ -310,7 +326,7 @@ var MailForward = common.Shortcut{
return err
}
} else {
composedTextBody = buildForwardedMessage(&orig, body)
composedTextBody = buildForwardedMessage(&orig, injectPlainTextSignature(body, sigResult))
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments regardless of body mode.

View File

@@ -42,6 +42,7 @@ var MailReply = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -93,7 +94,7 @@ var MailReply = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -129,12 +130,7 @@ var MailReply = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -156,6 +152,18 @@ var MailReply = common.Shortcut{
senderEmail = orig.headTo
}
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
// matches the correct usage. The actual image download in resolveSignature is deferred
// to after applyTemplate so the final plainText value (which a template can override
// via IsPlainTextMode) is used for the downloadImages decision.
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
}
replyTo := orig.replyTo
if replyTo == "" {
replyTo = orig.headFrom
@@ -208,6 +216,14 @@ var MailReply = common.Shortcut{
"bccs_count": countAddresses(bccFlag),
})
}
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
// override from the template. This avoids downloading HTML signature images when the
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if sigErr != nil {
return sigErr
}
// --subject (explicit override) takes precedence over auto-generated.
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
@@ -311,7 +327,7 @@ var MailReply = common.Shortcut{
return err
}
} else {
composedTextBody = bodyStr + quoted
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments regardless of body mode.

View File

@@ -43,6 +43,7 @@ var MailReplyAll = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -94,7 +95,7 @@ var MailReplyAll = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -131,12 +132,7 @@ var MailReplyAll = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -158,6 +154,18 @@ var MailReplyAll = common.Shortcut{
senderEmail = orig.headTo
}
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
// matches the correct usage. The actual image download in resolveSignature is deferred
// to after applyTemplate so the final plainText value (which a template can override
// via IsPlainTextMode) is used for the downloadImages decision.
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
}
var removeList []string
for _, r := range strings.Split(removeFlag, ",") {
if s := strings.TrimSpace(r); s != "" {
@@ -218,6 +226,14 @@ var MailReplyAll = common.Shortcut{
"bccs_count": countAddresses(bccFlag),
})
}
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
// override from the template. This avoids downloading HTML signature images when the
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if sigErr != nil {
return sigErr
}
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
if err := validateRecipientCount(toList, ccList, bccFlag); err != nil {
@@ -316,7 +332,7 @@ var MailReplyAll = common.Shortcut{
return err
}
} else {
composedTextBody = bodyStr + quoted
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments regardless of body mode.

View File

@@ -40,6 +40,7 @@ var MailSend = common.Shortcut{
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -98,7 +99,7 @@ var MailSend = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
// Resolve the body content first (reading --body-file if set) so
@@ -145,6 +146,14 @@ var MailSend = common.Shortcut{
mailboxID := resolveComposeMailboxID(runtime)
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
}
// --template-id merge: fetch template and apply it to compose state.
var templateLargeAttachmentIDs []string
var templateInlineAttachments []templateInlineRef
@@ -195,7 +204,8 @@ var MailSend = common.Shortcut{
}
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if err != nil {
return err
}
@@ -230,7 +240,7 @@ var MailSend = common.Shortcut{
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
composedTextBody = body
composedTextBody = injectPlainTextSignature(body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.

View File

@@ -6,6 +6,7 @@ package signature
import (
"encoding/json"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -55,6 +56,33 @@ func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error)
return resp.Signatures, nil
}
// DefaultSendID returns the default send-mail signature ID for the given
// sender email address. Returns "" if no default is configured.
// "0" and empty string are treated as "no default" (API convention).
func DefaultSendID(usages []SignatureUsage, emailAddr string) string {
for _, u := range usages {
if strings.EqualFold(u.EmailAddress, emailAddr) {
if u.SendMailSignatureID != "" && u.SendMailSignatureID != "0" {
return u.SendMailSignatureID
}
}
}
return ""
}
// DefaultReplyID returns the default reply/forward signature ID for the given
// sender email address. Returns "" if no default is configured.
func DefaultReplyID(usages []SignatureUsage, emailAddr string) string {
for _, u := range usages {
if strings.EqualFold(u.EmailAddress, emailAddr) {
if u.ReplySignatureID != "" && u.ReplySignatureID != "0" {
return u.ReplySignatureID
}
}
}
return ""
}
// Get returns a single signature by ID. Returns an error if not found.
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
resp, err := ListAll(runtime, mailboxID)

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"testing"
)
func TestDefaultSendID_Match(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-1"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-1" {
t.Fatalf("expected sig-send-1, got %q", got)
}
}
func TestDefaultSendID_CaseInsensitive(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "User@Example.COM", SendMailSignatureID: "sig-send-x"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-x" {
t.Fatalf("expected case-insensitive match, got %q", got)
}
}
func TestDefaultSendID_NoMatch(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "other@example.com", SendMailSignatureID: "sig-other"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for no match, got %q", got)
}
}
func TestDefaultSendID_ZeroIDTreatedAsNone(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", SendMailSignatureID: "0"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for ID=0, got %q", got)
}
}
func TestDefaultSendID_NilUsages(t *testing.T) {
if got := DefaultSendID(nil, "user@example.com"); got != "" {
t.Fatalf("expected empty string for nil usages, got %q", got)
}
}
func TestDefaultReplyID_Match(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-2"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-2" {
t.Fatalf("expected sig-reply-2, got %q", got)
}
}
func TestDefaultReplyID_CaseInsensitive(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "User@Example.COM", ReplySignatureID: "sig-reply-x"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-x" {
t.Fatalf("expected case-insensitive match, got %q", got)
}
}
func TestDefaultReplyID_NoMatch(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "other@example.com", ReplySignatureID: "sig-reply-other"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for no match, got %q", got)
}
}
func TestDefaultReplyID_ZeroIDTreatedAsNone(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", ReplySignatureID: "0"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for ID=0, got %q", got)
}
}
func TestDefaultReplyID_NilUsages(t *testing.T) {
if got := DefaultReplyID(nil, "user@example.com"); got != "" {
t.Fatalf("expected empty string for nil usages, got %q", got)
}
}

View File

@@ -5,6 +5,7 @@ package mail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
@@ -25,6 +26,54 @@ var signatureFlag = common.Flag{
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
}
// noSignatureFlag is shared by all 5 compose shortcuts.
var noSignatureFlag = common.Flag{
Name: "no-signature",
Type: "bool",
Desc: "Skip automatic default signature insertion. Mutually exclusive with --signature-id.",
}
// validateNoSignatureConflict returns a structured validation error when
// --no-signature and --signature-id are both set; they are mutually exclusive.
func validateNoSignatureConflict(noSignature bool, signatureID string) error {
if noSignature && signatureID != "" {
return mailValidationParamError("--no-signature", "--no-signature and --signature-id are mutually exclusive")
}
return nil
}
// autoResolveSignatureID resolves the default signature ID for the given mailbox/sender.
// isReply=true uses DefaultReplyID (+reply/+reply-all/+forward);
// isReply=false uses DefaultSendID (+send/+draft-create).
// Returns "" on API failure (writes stderr warning) or when no default is configured.
func autoResolveSignatureID(runtime *common.RuntimeContext, mailboxID, senderEmail string, isReply bool) string {
resp, err := signature.ListAll(runtime, mailboxID)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"warning: failed to fetch default signature: %v; sending without signature\n", err)
return ""
}
if isReply {
return signature.DefaultReplyID(resp.Usages, senderEmail)
}
return signature.DefaultSendID(resp.Usages, senderEmail)
}
// injectPlainTextSignature appends a plain-text rendering of the signature to a
// plain-text body. The HTML signature (sig.RenderedContent) is converted via
// draftpkg.PlainTextFromHTML; inline images are dropped (plain text has none).
// Returns textBody unchanged when sig is nil.
func injectPlainTextSignature(textBody string, sig *signatureResult) string {
if sig == nil {
return textBody
}
sigText := strings.TrimRight(draftpkg.PlainTextFromHTML(sig.RenderedContent), "\n")
if sigText == "" {
return textBody
}
return textBody + "\n\n" + sigText
}
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
ID string
@@ -32,16 +81,32 @@ type signatureResult struct {
Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// resolveSignature fetches, interpolates, and optionally downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
//
// userExplicit must be true when the caller obtained signatureID from a user-supplied flag
// (--signature-id); false when the ID was auto-resolved from default usages. When false,
// a "not found" error from the signatures API is treated as graceful degradation (no
// signature) rather than a hard failure — this protects against stale default IDs.
//
// includeImages controls whether inline image attachments are downloaded. Pass false for
// plain-text compose paths to avoid unnecessary network I/O (images are discarded in
// plain-text mode anyway).
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, userExplicit, includeImages bool) (*signatureResult, error) {
if signatureID == "" {
return nil, nil
}
sig, err := signature.Get(runtime, mailboxID, signatureID)
if err != nil {
if !userExplicit && errs.IsValidation(err) {
// Stale auto-resolved default signature ID — degrade gracefully instead of
// blocking the entire send/reply/forward operation.
fmt.Fprintf(runtime.IO().ErrOut,
"warning: default signature %q not found in current list; sending without signature\n", signatureID)
return nil, nil
}
return nil, err
}
@@ -50,23 +115,26 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
// Download signature inline images. The file_key field contains a
// direct download URL provided by the mail backend.
// Download signature inline images only when the compose path needs them.
// Plain-text paths discard images, so skip the download to avoid unnecessary
// network I/O (and potential failures from expired pre-signed URLs).
var images []draftpkg.SignatureImage
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
if includeImages {
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
return &signatureResult{
@@ -243,15 +311,3 @@ func signatureCIDs(sig *signatureResult) []string {
}
return cids
}
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return mailValidationError("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode").
WithParams(
mailInvalidParam("--plain-text", "mutually exclusive with --signature-id"),
mailInvalidParam("--signature-id", "requires HTML mode"),
)
}
return nil
}

View File

@@ -4,204 +4,287 @@
package mail
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
)
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{})
func TestValidateNoSignatureConflictTypedError(t *testing.T) {
err := validateNoSignatureConflict(true, "sig_123")
if err == nil {
t.Fatal("expected error, got nil")
}
// mailValidationParamError returns *errs.ValidationError.
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Param != "--no-signature" {
t.Errorf("expected Param \"--no-signature\", got %q", valErr.Param)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error message = %q, want it to contain \"mutually exclusive\"", err.Error())
}
}
func TestValidateNoSignatureConflictNoError(t *testing.T) {
if err := validateNoSignatureConflict(false, "sig_123"); err != nil {
t.Fatalf("expected no error when noSignature=false, got %v", err)
}
if err := validateNoSignatureConflict(true, ""); err != nil {
t.Fatalf("expected no error when signatureID empty, got %v", err)
}
}
func TestInjectPlainTextSignatureNilSig(t *testing.T) {
body := "Hello world"
got := injectPlainTextSignature(body, nil)
if got != body {
t.Fatalf("expected unchanged body %q, got %q", body, got)
}
}
func TestInjectPlainTextSignatureEmptyHTML(t *testing.T) {
sig := &signatureResult{RenderedContent: " <br> "}
body := "Hello world"
got := injectPlainTextSignature(body, sig)
// PlainTextFromHTML on whitespace-only HTML collapses to empty — body unchanged.
if got != body {
t.Fatalf("expected unchanged body for empty HTML sig, got %q", got)
}
}
func TestInjectPlainTextSignatureAppendsWithBlankLine(t *testing.T) {
sig := &signatureResult{RenderedContent: "<div>Best,<br>Bob</div>"}
body := "Hello world"
got := injectPlainTextSignature(body, sig)
if !strings.HasPrefix(got, body+"\n\n") {
t.Fatalf("expected body followed by two newlines, got %q", got)
}
if !strings.Contains(got, "Best,") || !strings.Contains(got, "Bob") {
t.Fatalf("expected sig text in result, got %q", got)
}
}
func TestInjectPlainTextSignatureTrimsTrailingNewlines(t *testing.T) {
// RenderedContent whose plain-text rendering ends in newlines must be trimmed.
sig := &signatureResult{RenderedContent: "<p>Alice</p>"}
body := "My message"
got := injectPlainTextSignature(body, sig)
// Result must not end with a bare newline after the signature text.
if strings.HasSuffix(got, "\n") {
t.Fatalf("result should not end with newline, got %q", got)
}
if !strings.Contains(got, "Alice") {
t.Fatalf("expected sig text in result, got %q", got)
}
}
func TestContentTypeFromFilename(t *testing.T) {
cases := []struct {
name string
url string
want string
}{
{name: "invalid", url: "https://[::1"},
{name: "http", url: "http://example.com/sig.png"},
{name: "no host", url: "https:///sig.png"},
{"logo.png", "image/png"},
{"photo.jpg", "image/jpeg"},
{"photo.jpeg", "image/jpeg"},
{"anim.gif", "image/gif"},
{"icon.webp", "image/webp"},
{"draw.svg", "image/svg+xml"},
{"bitmap.bmp", "image/bmp"},
{"data.bin", "application/octet-stream"},
{"noext", "application/octet-stream"},
{"UPPER.PNG", "image/png"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := downloadSignatureImage(rt, tc.url, "sig.png")
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected internal error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidResponse)
}
})
got := contentTypeFromFilename(tc.name)
if got != tc.want {
t.Errorf("contentTypeFromFilename(%q) = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestDownloadSignatureImageHTTPErrorClassification(t *testing.T) {
for _, tc := range []struct {
name string
statusCode int
wantType any
wantSub errs.Subtype
retryable bool
}{
{
name: "server",
statusCode: http.StatusInternalServerError,
wantType: (*errs.NetworkError)(nil),
wantSub: errs.SubtypeNetworkServer,
retryable: true,
func TestSignatureCIDsNilSig(t *testing.T) {
if cids := signatureCIDs(nil); cids != nil {
t.Fatalf("expected nil slice for nil sig, got %v", cids)
}
}
func TestSignatureCIDsFiltersEmpty(t *testing.T) {
sig := &signatureResult{
Images: []draftpkg.SignatureImage{
{CID: "abc123"},
{CID: ""},
{CID: "<def456>"},
},
{
name: "not found",
statusCode: http.StatusNotFound,
wantType: (*errs.APIError)(nil),
wantSub: errs.SubtypeNotFound,
}
cids := signatureCIDs(sig)
// normalizeInlineCID strips angle brackets; empty CID is filtered out.
if len(cids) != 2 {
t.Fatalf("expected 2 CIDs, got %d: %v", len(cids), cids)
}
for _, c := range cids {
if c == "" {
t.Errorf("CID must not be empty string; got %v", cids)
}
}
}
func TestInjectSignatureIntoBodyNilSig(t *testing.T) {
html := "<div>body</div>"
got := injectSignatureIntoBody(html, nil)
if got != html {
t.Fatalf("expected unchanged body for nil sig, got %q", got)
}
}
func TestInjectSignatureIntoBodyInjectsSig(t *testing.T) {
html := "<div>Hello</div>"
sig := &signatureResult{
ID: "sig1",
RenderedContent: "<div>-- Alice</div>",
}
got := injectSignatureIntoBody(html, sig)
if !strings.Contains(got, "sig1") && !strings.Contains(got, "Alice") {
t.Fatalf("expected signature content in result, got %q", got)
}
}
func TestAddSignatureImagesToBuilderNilSig(t *testing.T) {
bld := emlbuilder.New()
got := addSignatureImagesToBuilder(bld, nil)
// nil sig must return the builder unchanged (no panic, no nil return).
_ = got
}
func TestAddSignatureImagesToBuilderWithImages(t *testing.T) {
bld := emlbuilder.New()
sig := &signatureResult{
Images: []draftpkg.SignatureImage{
{CID: "img1", ContentType: "image/png", FileName: "logo.png", Data: []byte("fake")},
{CID: "", ContentType: "image/jpeg", FileName: "skip.jpg", Data: []byte("fake")}, // empty CID skipped
},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "download failed", tc.statusCode)
}))
t.Cleanup(srv.Close)
rt := newDownloadRuntime(t, srv.Client())
}
// Should not panic; empty CID entry is silently skipped.
got := addSignatureImagesToBuilder(bld, sig)
_ = got
}
_, _, err := downloadSignatureImage(rt, srv.URL+"/sig.png", "sig.png")
switch tc.wantType.(type) {
case *errs.NetworkError:
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("expected network error, got %T (%v)", err, err)
}
case *errs.APIError:
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected API error, got %T (%v)", err, err)
}
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Code != tc.statusCode {
t.Fatalf("code = %d, want %d", p.Code, tc.statusCode)
}
if p.Subtype != tc.wantSub {
t.Fatalf("subtype = %q, want %q", p.Subtype, tc.wantSub)
}
if p.Retryable != tc.retryable {
t.Fatalf("retryable = %v, want %v", p.Retryable, tc.retryable)
}
})
// newSigTestRuntime creates a RuntimeContext backed by an httpmock.Registry for
// tests that exercise signature API code paths (autoResolveSignatureID, resolveSignature).
func newSigTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_sigtest"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+test"}, cfg, f, core.AsUser)
return rt, reg
}
// stubSigListResponse registers a signatures list stub for the given mailboxID.
func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[string]interface{}, usages []map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/mail/v1/user_mailboxes/" + mailboxID + "/settings/signatures",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"signatures": sigs,
"usages": usages,
},
},
})
}
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
rt, reg := newSigTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/mail/v1/user_mailboxes/mbx-api-fail/settings/signatures",
Status: 500,
Body: map[string]interface{}{"code": 500, "msg": "internal server error"},
})
got := autoResolveSignatureID(rt, "mbx-api-fail", "user@example.com", false)
if got != "" {
t.Fatalf("expected empty string on API failure, got %q", got)
}
}
func TestDownloadSignatureImageReadAndSizeErrors(t *testing.T) {
readErr := errors.New("socket closed")
rt := newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: signatureErrorBody{err: readErr},
Request: req,
}, nil
}),
func TestAutoResolveSignatureID_NoDefaultConfigured(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-no-default", nil, []map[string]interface{}{
{"email_address": "other@example.com", "send_mail_signature_id": "sig-other"},
})
_, _, err := downloadSignatureImage(rt, "https://example.com/sig.png", "sig.png")
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("expected network error, got %T (%v)", err, err)
}
if !errors.Is(err, readErr) {
t.Fatalf("read cause not preserved: %v", err)
}
rt = newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: &bodyFileTestFile{remaining: 10*1024*1024 + 1},
Request: req,
}, nil
}),
})
_, _, err = downloadSignatureImage(rt, "https://example.com/huge.png", "huge.png")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFailedPrecondition)
got := autoResolveSignatureID(rt, "mbx-no-default", "user@example.com", false)
if got != "" {
t.Fatalf("expected empty string when no default configured for sender, got %q", got)
}
}
func TestDownloadSignatureImageSuccessUsesFilenameContentType(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("gif-data")),
Request: req,
}, nil
}),
func TestAutoResolveSignatureID_ReturnsSendID(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-send-id", nil, []map[string]interface{}{
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
})
got := autoResolveSignatureID(rt, "mbx-send-id", "user@example.com", false)
if got != "sig-send-42" {
t.Fatalf("expected send default sig ID %q, got %q", "sig-send-42", got)
}
}
data, contentType, err := downloadSignatureImage(rt, "https://example.com/sig.gif", "sig.gif")
func TestAutoResolveSignatureID_ReturnsReplyID(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-reply-id", nil, []map[string]interface{}{
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
})
got := autoResolveSignatureID(rt, "mbx-reply-id", "user@example.com", true)
if got != "sig-reply-42" {
t.Fatalf("expected reply default sig ID %q, got %q", "sig-reply-42", got)
}
}
func TestResolveSignature_EmptyIDReturnsNil(t *testing.T) {
rt, _ := newSigTestRuntime(t)
result, err := resolveSignature(context.Background(), rt, "mbx-empty", "", "user@example.com", false, false)
if err != nil {
t.Fatalf("downloadSignatureImage failed: %v", err)
t.Fatalf("unexpected error for empty signatureID: %v", err)
}
if string(data) != "gif-data" {
t.Fatalf("data = %q", string(data))
}
if contentType != "image/gif" {
t.Fatalf("content type = %q, want image/gif", contentType)
if result != nil {
t.Fatalf("expected nil result for empty signatureID, got %+v", result)
}
}
func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
err := validateSignatureWithPlainText(true, "sig_123")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
func TestResolveSignature_StaleIDAutoDegradesGracefully(t *testing.T) {
rt, reg := newSigTestRuntime(t)
// API returns an empty list — stale ID not found → ValidationError in Get.
stubSigListResponse(reg, "mbx-stale-auto", nil, nil)
result, err := resolveSignature(context.Background(), rt, "mbx-stale-auto", "sig-stale", "user@example.com", false, false)
if err != nil {
t.Fatalf("expected graceful degradation (nil error), got: %v", err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
}
if validationErr.Params[0].Name != "--plain-text" || validationErr.Params[1].Name != "--signature-id" {
t.Fatalf("unexpected params: %#v", validationErr.Params)
if result != nil {
t.Fatalf("expected nil result for stale auto-resolved ID, got %+v", result)
}
}
type signatureRoundTripper func(*http.Request) (*http.Response, error)
func (rt signatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt(req)
}
type signatureErrorBody struct {
err error
}
func (b signatureErrorBody) Read([]byte) (int, error) {
return 0, b.err
}
func (b signatureErrorBody) Close() error {
return nil
func TestResolveSignature_StaleIDUserExplicitFails(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-stale-explicit", nil, nil)
_, err := resolveSignature(context.Background(), rt, "mbx-stale-explicit", "sig-stale", "user@example.com", true, false)
if err == nil {
t.Fatal("expected error for stale ID with userExplicit=true, got nil")
}
if !errs.IsValidation(err) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
}

View File

@@ -177,18 +177,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,

View File

@@ -150,12 +150,6 @@ var batchOpDispatch = map[string]batchOpMapping{
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
}},
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},

View File

@@ -25,119 +25,6 @@
}
]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "cursor",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Pagination cursor; pass the previous page's next_cursor, omit for first page"
},
{
"name": "count",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "History versions per page, default 20"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "revision-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Restore the whole spreadsheet to this version: a revision_id (minor id) from +history-list"
},
{
"name": "edit-time",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "The matching edit_time from the same +history-list entry; pass it to locate the version faster"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert-status": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "task-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Revert task id returned by +history-revert"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": {
"risk": "write",
"flags": [
@@ -167,7 +54,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Insert position (0-based); appended to the end when omitted",
"desc": "Insert position; appended to the end when omitted",
"default": "-1"
},
{
@@ -526,86 +413,6 @@
}
]
},
"+sheet-hide-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-show-gridline": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+workbook-create": {
"risk": "write",
"flags": [
@@ -623,46 +430,28 @@
"required": "optional",
"desc": "Target folder token; placed at the drive root when omitted"
},
{
"name": "headers",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
"input": [
"file",
"stdin"
]
},
{
"name": "values",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
"input": [
"file",
"stdin"
]
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
"input": [
"file",
"stdin"
]
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
},
{
"name": "dry-run",
"kind": "system",
@@ -724,32 +513,6 @@
}
]
},
"+workbook-import": {
"risk": "write",
"flags": [
{
"name": "file",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Local file path (.xlsx / .xls / .csv)"
},
{
"name": "folder-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Target folder token; imported to the cloud drive root when omitted"
},
{
"name": "name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
}
]
},
"+sheet-info": {
"risk": "read",
"flags": [
@@ -1449,72 +1212,19 @@
"desc": "Skip hidden rows and columns; default `false`"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
}
]
},
"+table-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by id); omit to read all sheets"
},
{
"name": "sheet-name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by name); omit to read all sheets"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "A1 range to read; omit to read each sheet current region"
},
{
"name": "no-header",
"name": "rows-json",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dataframe-out",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
"default": "false"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
"desc": "Print the request path and parameters without executing"
}
]
},
@@ -2139,7 +1849,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
"input": [
"file",
"stdin"
@@ -2170,61 +1880,6 @@
}
]
},
"+table-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token to write into (XOR with `--url`)"
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+cells-clear": {
"risk": "high-risk-write",
"flags": [

View File

@@ -462,21 +462,8 @@
"type": "string"
},
"mention_type": {
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
"type": "number",
"enum": [
0,
1,
3,
8,
11,
12,
15,
16,
22,
30,
38
]
"description": "@提及类型编号(仅 type='mention' 时可选)",
"type": "number"
},
"notify": {
"description": "是否发送通知(仅 type='mention' 时可选,默认 true",
@@ -1743,12 +1730,11 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -1801,7 +1787,11 @@
"data"
]
}
}
},
"required": [
"position",
"size"
]
}
},
"+chart-update": {
@@ -2779,12 +2769,11 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -2837,7 +2826,11 @@
"data"
]
}
}
},
"required": [
"position",
"size"
]
}
},
"+cond-format-create": {
@@ -6256,744 +6249,6 @@
}
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
},
"+workbook-create": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
}
}
}

View File

@@ -365,17 +365,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
},
},
}
// The write reads the workbook structure to resolve the default sheet's id
// (the create response doesn't echo it). lookupFirstSheetID and
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
// so it's adopted in place (no rename).
// Initial fill first reads the workbook structure to resolve the default
// sheet's id (the create response doesn't echo it), then writes.
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--headers", `["Name","Score"]`,
"--values", `[["alice",95]]`,
}, create, structure, fill)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
@@ -385,8 +382,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
if ss["spreadsheet_token"] != "shtcnBRAND" {
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
}
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
if data["initial_fill"] == nil {
t.Errorf("initial_fill missing in envelope")
}
// The fill must target the resolved first sheet, not an empty selector.
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
@@ -396,13 +393,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
// (no structure/fill calls fire) and finish with the spreadsheet created but no
// sheets summary — never panic on a nil payload.
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -423,8 +421,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["sheets"] != nil {
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])

View File

@@ -308,6 +308,7 @@ var flagDefs = map[string]commandDef{
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
@@ -319,7 +320,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -632,35 +633,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "cursor", Kind: "own", Type: "string", Required: "optional", Desc: "Pagination cursor; pass the previous page's next_cursor, omit for first page"},
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "History versions per page, default 20"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "revision-id", Kind: "own", Type: "string", Required: "required", Desc: "Restore the whole spreadsheet to this version: a revision_id (minor id) from +history-list"},
{Name: "edit-time", Kind: "own", Type: "string", Required: "optional", Desc: "The matching edit_time from the same +history-list entry; pass it to locate the version faster"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert-status": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "task-id", Kind: "own", Type: "string", Required: "required", Desc: "Revert task id returned by +history-revert"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
Risk: "write",
Flags: []flagDef{
@@ -794,7 +766,7 @@ var flagDefs = map[string]commandDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -821,16 +793,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
Risk: "read",
Flags: []flagDef{
@@ -877,16 +839,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-show-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
Risk: "write",
Flags: []flagDef{
@@ -943,39 +895,13 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -990,14 +916,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{

View File

@@ -63,7 +63,6 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
var parseJSONFlagSkip = map[string]struct{}{
"properties": {},
"operations": {},
"styles": {},
}
// validateValueAgainstSchema is the (command, flag) → schema → check

View File

@@ -32,6 +32,4 @@ var commandsWithSchema = map[string]struct{}{
"+range-sort": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -1,553 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
//
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
// writes), single schema, optionally multi-batch. Type / format are read off
// the Arrow schema (no separate dtypes/formats maps), and per-column number
// format can be set via the field's `number_format` metadata key:
//
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
//
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
// (adopted in place by +workbook-create; created when absent by +table-put),
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
// surface is deliberately the one flag — anything that needs a different
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
// JSON payload already carries every knob.
//
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
// stream byte-exact. "-" reads from stdin directly.
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
// Matches valuesSheetName so +workbook-create adopts the brand-new
// workbook's default sheet in place (no stray empty Sheet1 left behind);
// +table-put creates Sheet1 if it doesn't already exist.
const dataframeDefaultSheetName = valuesSheetName
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
// composes a single-sheet tablePayload at the fixed default placement.
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
// so downstream is unaware of where the rows came from.
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
raw := strings.TrimSpace(rctx.Str("dataframe"))
if raw == "" {
return nil, common.FlagErrorf("--dataframe is required")
}
data, err := readDataframeBytes(rctx, raw)
if err != nil {
return nil, err
}
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
if err != nil {
return nil, common.FlagErrorf("--dataframe: %v", err)
}
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
if err := payload.validate(); err != nil {
return nil, err
}
return payload, nil
}
// dataframeStdinCache holds the bytes read from stdin on the first call so a
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
// empty stream — stdin is single-shot, but parseDataframePayload runs
// multiple times per command invocation. Process-wide is fine: lark-cli is
// one-shot (one command per process). Tests reset by setting it back to nil.
var dataframeStdinCache []byte
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
// both work). `-` reads stdin verbatim — cached on first call so Validate /
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
if raw == "-" {
if dataframeStdinCache != nil {
return dataframeStdinCache, nil
}
io := rctx.IO()
if io == nil || io.In == nil {
return nil, common.FlagErrorf("--dataframe: stdin is not available")
}
data, err := readAllBytes(io.In)
if err != nil {
return nil, common.FlagErrorf("--dataframe: read stdin: %v", err)
}
if len(data) == 0 {
return nil, common.FlagErrorf("--dataframe: stdin is empty")
}
dataframeStdinCache = data
return data, nil
}
path := strings.TrimPrefix(raw, "@")
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return nil, common.FlagErrorf("--dataframe: %v", err)
}
if len(data) == 0 {
return nil, common.FlagErrorf("--dataframe: file %q is empty", path)
}
return data, nil
}
// readAllBytes is a thin wrapper so tests can fake the io.Reader without
// importing io. Mirrors io.ReadAll exactly.
func readAllBytes(r io.Reader) ([]byte, error) { return io.ReadAll(r) }
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
// not touched here — parseDataframePayload layers those on from CLI flags.
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
reader, err := ipc.NewFileReader(bytes.NewReader(data))
if err != nil {
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %v", err)
}
defer reader.Close()
schema := reader.Schema()
if schema == nil || schema.NumFields() == 0 {
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields")
}
ncols := schema.NumFields()
cols := make([]tableColumnSpec, ncols)
seen := make(map[string]bool, ncols)
for i := 0; i < ncols; i++ {
f := schema.Field(i)
name := f.Name
if strings.TrimSpace(name) == "" {
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i)
}
if seen[name] {
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name)
}
seen[name] = true
typ, format, err := arrowFieldToTypeFormat(f)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("column %q: %v", name, err)
}
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
}
var rows [][]interface{}
for b := 0; b < reader.NumRecords(); b++ {
rec, err := reader.RecordAt(b)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %v", b, err)
}
batchRows, err := arrowRecordToRows(rec, cols)
rec.Release()
if err != nil {
return tableSheetSpec{}, err
}
rows = append(rows, batchRows...)
}
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
}
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
// pair. The field's `number_format` metadata key — when present — sets the
// Excel number_format string verbatim; otherwise sensible defaults are
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
if v, ok := f.Metadata.GetValue("number_format"); ok {
format = strings.TrimSpace(v)
}
switch f.Type.(type) {
case *arrow.StringType, *arrow.LargeStringType:
if format == "" {
format = "@"
}
return "string", format, nil
case *arrow.BooleanType:
return "bool", format, nil
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
if format == "" {
format = "yyyy-mm-dd"
}
return "date", format, nil
}
if isArrowNumericType(f.Type) {
return "number", format, nil
}
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name())
}
func isArrowNumericType(t arrow.DataType) bool {
switch t.ID() {
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
return true
}
return false
}
// arrowRecordToRows transposes one column-batch into row-major
// [][]interface{} matched to `cols`. Cells are stamped with the same value
// shapes buildTypedCell expects from the JSON path: nil for nulls,
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
// dates/timestamps, bool for booleans, string for strings.
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
if int(rec.NumCols()) != len(cols) {
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols))
}
nrows := int(rec.NumRows())
rows := make([][]interface{}, nrows)
for r := range rows {
rows[r] = make([]interface{}, len(cols))
}
for c := range cols {
arr := rec.Column(c)
for r := 0; r < nrows; r++ {
if arr.IsNull(r) {
continue
}
v, err := arrowCellValue(arr, r)
if err != nil {
return nil, fmt.Errorf("row %d column %q: %v", r, cols[c].Name, err)
}
rows[r][c] = v
}
}
return rows, nil
}
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
switch a := arr.(type) {
case *array.String:
return a.Value(i), nil
case *array.LargeString:
return a.Value(i), nil
case *array.Boolean:
return a.Value(i), nil
case *array.Int8:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int16:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int32:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int64:
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
case *array.Uint8:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint16:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint32:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint64:
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
case *array.Float16:
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
case *array.Float32:
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
case *array.Float64:
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
case *array.Date32:
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
// in UTC so timezone offset can't flip the calendar date.
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
return t.Format("2006-01-02"), nil
case *array.Date64:
t := time.UnixMilli(int64(a.Value(i))).UTC()
return t.Format("2006-01-02"), nil
case *array.Timestamp:
ts := int64(a.Value(i))
unit := a.DataType().(*arrow.TimestampType).Unit
var t time.Time
switch unit {
case arrow.Second:
t = time.Unix(ts, 0).UTC()
case arrow.Millisecond:
t = time.UnixMilli(ts).UTC()
case arrow.Microsecond:
t = time.UnixMicro(ts).UTC()
case arrow.Nanosecond:
t = time.Unix(0, ts).UTC()
default:
return nil, fmt.Errorf("unsupported timestamp unit %v", unit)
}
return t.Format("2006-01-02"), nil
}
return nil, fmt.Errorf("unsupported Arrow array %T", arr)
}
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
//
// +table-get's binary read-back: encode one sheet's typed read-back as an
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
// is unchanged; --dataframe-out swaps the encoder for callers that already
// have pandas / pyarrow in their pipeline.
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
// file blob. Internal column types are recovered from `dtypes` (the wire
// proxy for the typed protocol), and per-column `number_format` rides through
// as Arrow field metadata so the file feeds straight back into
// `+table-put --dataframe`.
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
columns, _ := sheet["columns"].([]interface{})
if len(columns) == 0 {
return nil, fmt.Errorf("sheet has no columns")
}
dtypes, _ := sheet["dtypes"].(map[string]interface{})
formats, _ := sheet["formats"].(map[string]interface{})
// `data` arrives as either []interface{} (when the sheet came through a
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
// readSheetAsSpec directly emits in production). Accept both — anything
// else falls through to a zero-row table.
var rawData [][]interface{}
switch d := sheet["data"].(type) {
case [][]interface{}:
rawData = d
case []interface{}:
rawData = make([][]interface{}, len(d))
for i, r := range d {
rawData[i], _ = r.([]interface{})
}
}
ncols := len(columns)
colNames := make([]string, ncols)
colTypes := make([]string, ncols)
fields := make([]arrow.Field, ncols)
for i, c := range columns {
name, _ := c.(string)
if name == "" {
return nil, fmt.Errorf("column %d has empty name", i)
}
colNames[i] = name
dt, _ := dtypes[name].(string)
colTypes[i] = dtypeToInternalType(dt)
var meta arrow.Metadata
if formats != nil {
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
}
}
fields[i] = arrow.Field{
Name: name,
Type: internalTypeToArrowType(colTypes[i]),
Nullable: true,
Metadata: meta,
}
}
schema := arrow.NewSchema(fields, nil)
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
for r, row := range rawData {
if len(row) != ncols {
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols)
}
for c := 0; c < ncols; c++ {
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
return nil, fmt.Errorf("row %d column %q: %v", r, colNames[c], err)
}
}
}
rec := rb.NewRecord()
defer rec.Release()
var buf bytesWriterSeeker
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
return nil, fmt.Errorf("ipc.NewFileWriter: %v", err)
}
if err := w.Write(rec); err != nil {
return nil, fmt.Errorf("write record: %v", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close writer: %v", err)
}
return buf.buf, nil
}
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
// internal column type from the wire-level dtype string. Unknown / object
// falls back to string (lossless: every cell is already typed as such).
func dtypeToInternalType(dtype string) string {
switch strings.ToLower(strings.TrimSpace(dtype)) {
case "float64", "float32", "int64", "int32", "int16", "int8",
"uint64", "uint32", "uint16", "uint8":
return "number"
case "bool", "boolean":
return "bool"
}
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
return "date"
}
return "string"
}
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
// internal column type to the Arrow data type the encoder builds a column
// with. Numbers go to float64 because +table-get can't tell int from float
// from a number_format alone — float64 covers both losslessly for the cell
// ranges Lark Sheets accepts.
func internalTypeToArrowType(typ string) arrow.DataType {
switch typ {
case "number":
return arrow.PrimitiveTypes.Float64
case "date":
return arrow.FixedWidthTypes.Date32
case "bool":
return arrow.FixedWidthTypes.Boolean
}
return arrow.BinaryTypes.String
}
// appendArrowCell stamps one cell into its column builder. Cell shape matches
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
// nil for empty. Anything off-shape errors so the caller doesn't silently
// emit nulls for malformed data.
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
if v == nil {
b.AppendNull()
return nil
}
switch typ {
case "string":
s, ok := v.(string)
if !ok {
return fmt.Errorf("string expects string value, got %T", v)
}
b.(*array.StringBuilder).Append(s)
case "number":
f, err := arrowNumber(v)
if err != nil {
return err
}
b.(*array.Float64Builder).Append(f)
case "date":
s, ok := v.(string)
if !ok {
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v)
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("date parse %q: %v", s, err)
}
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
case "bool":
bb, ok := v.(bool)
if !ok {
return fmt.Errorf("bool expects bool, got %T", v)
}
b.(*array.BooleanBuilder).Append(bb)
default:
return fmt.Errorf("unsupported internal type %q", typ)
}
return nil
}
// arrowNumber converts the number cell shape readSheetAsSpec emits
// (json.Number) plus the float fallback to float64 for the Arrow builder.
func arrowNumber(v interface{}) (float64, error) {
switch n := v.(type) {
case json.Number:
f, err := n.Float64()
if err != nil {
return 0, fmt.Errorf("number parse %q: %v", n.String(), err)
}
return f, nil
case float64:
return n, nil
}
return 0, fmt.Errorf("number expects numeric value, got %T", v)
}
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
// --dataframe-out's stdout path zero-IO and stays straightforward.
type bytesWriterSeeker struct {
buf []byte
pos int64
}
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
end := w.pos + int64(len(p))
if end > int64(len(w.buf)) {
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
}
n := copy(w.buf[w.pos:], p)
w.pos = end
return n, nil
}
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
w.pos = offset
case io.SeekCurrent:
w.pos += offset
case io.SeekEnd:
w.pos = int64(len(w.buf)) + offset
default:
return 0, fmt.Errorf("unknown whence %d", whence)
}
return w.pos, nil
}
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
if raw == "-" {
out := rctx.IO()
if out == nil || out.Out == nil {
return common.FlagErrorf("--dataframe-out: stdout is not available")
}
if _, err := out.Out.Write(data); err != nil {
return fmt.Errorf("--dataframe-out: write stdout: %v", err)
}
return nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return common.FlagErrorf("--dataframe-out: file output is not available in this context")
}
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
// readDataframeBytes hits on the input side) and writes atomically, so we
// don't need an extra ValidatePath call here.
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
return fmt.Errorf("--dataframe-out: write %q: %v", path, err)
}
return nil
}

View File

@@ -1,378 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
)
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
// Used by the round-trip tests below to stand in for what
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
// depending on a pandas-shaped fixture file.
//
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
// offset), so we write to a temp file and read the bytes back — simpler than
// re-implementing a seekable in-memory buffer.
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
t.Helper()
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
build(rb)
rec := rb.NewRecord()
defer rec.Release()
path := filepath.Join(t.TempDir(), "df.arrow")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create temp arrow file: %v", err)
}
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
f.Close()
t.Fatalf("ipc.NewFileWriter: %v", err)
}
if err := w.Write(rec); err != nil {
t.Fatalf("write record: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close file: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp arrow file: %v", err)
}
return data
}
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
// (type, format) mapping and the per-cell value shape that buildTypedCell
// expects: number cells are json.Number (precision-preserving), date cells
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
// quietly regress any one family.
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "name", Type: arrow.BinaryTypes.String},
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
[]string{"number_format"}, []string{"$#,##0.00"},
)},
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
}, nil)
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
})
spec, err := decodeArrowToSheet(buf, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
if spec.Name != "S1" {
t.Errorf("sheet name = %q, want S1", spec.Name)
}
if len(spec.Columns) != 5 {
t.Fatalf("got %d columns, want 5", len(spec.Columns))
}
want := []struct{ typ, format string }{
{"string", "@"},
{"number", ""},
{"number", "$#,##0.00"},
{"bool", ""},
{"date", "yyyy-mm-dd"},
}
for i, w := range want {
if spec.Columns[i].Type != w.typ {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
}
if spec.Columns[i].Format != w.format {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
// Row 0: every field present, types match what buildTypedCell will accept.
row0 := spec.Rows[0]
if row0[0] != "alice" {
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
}
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
}
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
}
if row0[3] != true {
t.Errorf("row0[active] = %#v, want true", row0[3])
}
if row0[4] != "2024-01-15" {
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
}
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
// so buildTypedCell paints an empty cell that still carries number_format.
row1 := spec.Rows[1]
for _, c := range []int{0, 1, 2} {
if row1[c] != nil {
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
}
}
if row1[3] != false {
t.Errorf("row1[active] = %#v, want false", row1[3])
}
if row1[4] != "2024-02-02" {
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
}
}
// TestDataframe_Timestamp pins the timestamp → date conversion for the
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
// landing — guard against TZ drift from the wrong unit pick.
func TestDataframe_Timestamp(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
}, nil)
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
})
spec, err := decodeArrowToSheet(buf, "S")
if err != nil {
t.Fatal(err)
}
if spec.Columns[0].Type != "date" {
t.Errorf("type = %q, want date", spec.Columns[0].Type)
}
if got := spec.Rows[0][0]; got != "2024-06-12" {
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
}
}
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
// a 0-column "DataFrame" would write a header-less, data-less block that
// validates as "writer ran successfully" but produces nothing — the test ties
// that off as an explicit error rather than letting it slip through.
func TestDataframe_EmptySchema(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema(nil, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "no fields") {
t.Errorf("err = %v, want 'no fields' error", err)
}
}
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
// time. Validate already rejects duplicate column names for the JSON path;
// the Arrow path mirrors that so the error surfaces with the same shape.
func TestDataframe_DuplicateColumn(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "x", Type: arrow.BinaryTypes.String},
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
}, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).Append("")
b.Field(1).(*array.Int64Builder).Append(0)
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("err = %v, want duplicate-column error", err)
}
}
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
// pandas df.to_feather so users see what producer is expected without having
// to grep the docs.
func TestDataframe_BadBytes(t *testing.T) {
t.Parallel()
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
if err == nil || !strings.Contains(err.Error(), "Arrow") {
t.Errorf("err = %v, want Arrow-decode error", err)
}
}
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
// own decoder: build a +table-get-shaped sheet map (the same one
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
// decoder, and require the column types / formats / row values to match. If
// any encoder choice drifts from what the decoder expects, the round-trip
// breaks here long before a real put → get round-trip in production would.
func TestDataframe_EncodeRoundTrip(t *testing.T) {
t.Parallel()
sheet := map[string]interface{}{
"name": "S1",
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
"dtypes": map[string]interface{}{
"name": "object",
"qty": "float64",
"price": "float64",
"active": "bool",
"ts": "datetime64[ns]",
},
"formats": map[string]interface{}{
// `@` is the writer convention for string columns; readSheetAsSpec
// strips it via isTextNumberFormat, so an Arrow file built from a
// real read won't carry @ either. Keep it absent here to mirror
// the production wire shape.
"price": "$#,##0.00",
},
"data": []interface{}{
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
},
}
blob, err := encodeSheetMapToArrowIPC(sheet)
if err != nil {
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
}
spec, err := decodeArrowToSheet(blob, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
wantTypes := []string{"string", "number", "number", "bool", "date"}
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
if len(spec.Columns) != len(wantTypes) {
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
}
for i, w := range wantTypes {
if spec.Columns[i].Type != w {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
}
if spec.Columns[i].Format != wantFormats[i] {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
if spec.Rows[0][0] != "alice" {
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
}
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
}
if spec.Rows[0][3] != true {
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
}
if spec.Rows[0][4] != "2024-01-15" {
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
}
// qty is null on row1, must come back as nil (not a zero-valued
// json.Number that would later round-trip as 0).
if spec.Rows[1][1] != nil {
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
}
}
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
// output — early on the production shape silently fell through the
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
t.Parallel()
base := func(data interface{}) map[string]interface{} {
return map[string]interface{}{
"name": "S",
"columns": []interface{}{"city"},
"dtypes": map[string]interface{}{"city": "object"},
"data": data,
}
}
for label, data := range map[string]interface{}{
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
} {
blob, err := encodeSheetMapToArrowIPC(base(data))
if err != nil {
t.Errorf("%s: encode: %v", label, err)
continue
}
spec, err := decodeArrowToSheet(blob, "S")
if err != nil {
t.Errorf("%s: decode: %v", label, err)
continue
}
if len(spec.Rows) != 2 {
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
}
}
}
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
// dtype families +table-get emits today plus the safe fallback for unknown
// labels (string, lossless).
func TestDataframe_DtypeToInternalType(t *testing.T) {
t.Parallel()
cases := map[string]string{
"float64": "number",
"int64": "number",
"Int64": "number",
"bool": "bool",
"boolean": "bool",
"datetime64[ns]": "date",
"datetime64[ms]": "date",
"object": "string",
"": "string",
"weird-new-dtype": "string",
}
for in, want := range cases {
if got := dtypeToInternalType(in); got != want {
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
}
}
}
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
// offset: write some bytes, seek back to the middle, overwrite, end up with
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
func TestDataframe_BytesWriterSeeker(t *testing.T) {
t.Parallel()
var w bytesWriterSeeker
if _, err := w.Write([]byte("hello world")); err != nil {
t.Fatal(err)
}
if _, err := w.Seek(6, 0); err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("WORLD")); err != nil {
t.Fatal(err)
}
if got := string(w.buf); got != "hello WORLD" {
t.Errorf("buf = %q, want \"hello WORLD\"", got)
}
}

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history ───────────────────────────────────────────────
//
// Wraps:
// - list_history_versions (read) — powers +history-list
// - revert_to_revision (write) — powers +history-revert
// - get_revert_status (read) — powers +history-revert-status
//
// The version-history "方案 B": list the spreadsheet's saved revisions, submit
// a whole-document revert to one of them, then poll the async revert task for
// completion. The facade gateway owns the work; the CLI only forwards the
// target revision (and later the task id) and the server does the rest.
//
// ⚠️ Full-table overwrite: +history-revert rolls the WHOLE spreadsheet back to
// the target revision, discarding every change made afterwards — including
// other collaborators' (and the web UI's) edits. Use it only on agent scratch
// spreadsheets, or when a whole-document rollback is acceptable.
// HistoryList wraps list_history_versions: page through the spreadsheet's saved
// revisions so a later +history-revert can target one by revision_id.
var HistoryList = common.Shortcut{
Service: "sheets",
Command: "+history-list",
Description: "List the spreadsheet's saved history versions (paginated); use a returned revision_id with +history-revert.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+history-list"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "list_history_versions", historyListInput(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "list_history_versions", historyListInput(runtime))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Omit --cursor for the first page; pass the previous response's next_cursor to fetch the next page.",
"Pick a revision_id from a listing entry and pass it (plus that entry's edit_time to --edit-time) to +history-revert to roll the whole spreadsheet back.",
},
}
// HistoryRevert wraps revert_to_revision: roll the whole spreadsheet back to a
// past revision. Returns an async task id to poll via +history-revert-status.
var HistoryRevert = common.Shortcut{
Service: "sheets",
Command: "+history-revert",
Description: "Roll the whole spreadsheet back to a past revision (full-document restore; discards all later edits).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+history-revert"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := historyRevertInput(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := historyRevertInput(runtime)
return invokeToolDryRun(token, ToolKindWrite, "revert_to_revision", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := historyRevertInput(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "revert_to_revision", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"+history-revert is a FULL-DOCUMENT rollback — it discards every edit made after the target version, including other collaborators'.",
"--revision-id takes a revision_id (minor id) from +history-list; pass the same entry's edit_time to --edit-time to locate the version faster. Poll the returned task id with +history-revert-status.",
},
}
// HistoryRevertStatus wraps get_revert_status: poll an async revert task
// started by +history-revert for completion.
var HistoryRevertStatus = common.Shortcut{
Service: "sheets",
Command: "+history-revert-status",
Description: "Poll the status of an async revert task started by +history-revert.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+history-revert-status"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := historyRevertStatusInput(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := historyRevertStatusInput(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_revert_status", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := historyRevertStatusInput(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_revert_status", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"--task-id is the task id returned by +history-revert; re-run this until the task reports completion.",
},
}
// historyListInput builds the list_history_versions tool body. Network-free;
// shared by DryRun and Execute. Both flags are optional: cursor is forwarded
// only when set, count only when a positive value is given.
func historyListInput(runtime flagView) map[string]interface{} {
input := map[string]interface{}{}
if cursor := strings.TrimSpace(runtime.Str("cursor")); cursor != "" {
input["cursor"] = cursor
}
if count := runtime.Int("count"); count > 0 {
input["count"] = count
}
return input
}
// historyRevertInput builds the revert_to_revision tool body. Network-free;
// shared by Validate, DryRun, and Execute. revision_id 是 +history-list 返回的
// revision_idminor idedit_time 可选,传同一条 entry 的 edit_time 让服务端更快定位该版本。
func historyRevertInput(runtime flagView) (map[string]interface{}, error) {
rev := strings.TrimSpace(runtime.Str("revision-id"))
if rev == "" {
return nil, common.FlagErrorf("--revision-id is required (a revision_id from +history-list)")
}
input := map[string]interface{}{
"revision_id": rev,
}
if et := strings.TrimSpace(runtime.Str("edit-time")); et != "" {
input["edit_time"] = et
}
return input, nil
}
// historyRevertStatusInput builds the get_revert_status tool body.
// Network-free; shared by Validate, DryRun, and Execute.
func historyRevertStatusInput(runtime flagView) (map[string]interface{}, error) {
tid := strings.TrimSpace(runtime.Str("task-id"))
if tid == "" {
return nil, common.FlagErrorf("--task-id is required")
}
return map[string]interface{}{
"task_id": tid,
}, nil
}

View File

@@ -49,12 +49,6 @@ type objectCRUDSpec struct {
// right nesting level.
enhanceCreateInput func(rt flagView, input map[string]interface{})
enhanceUpdateInput func(rt flagView, input map[string]interface{})
// validateCreateInput, when set, runs after enhanceCreateInput to
// enforce *cross-flag, create-only* constraints JSON Schema can't
// express (e.g. pivot rejects --target-position vs --range when
// both carry non-default values — they map to the same wire field
// and conflicting values are ambiguous). Mirrors validateUpdateInput.
validateCreateInput func(rt flagView, input map[string]interface{}) error
// validateUpdateInput, when set, runs after enhanceUpdateInput to
// enforce *cross-field, update-only* constraints JSON Schema can't
// express (e.g. sparkline requires properties.sparklines[i] to
@@ -196,11 +190,6 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
if spec.enhanceCreateInput != nil {
spec.enhanceCreateInput(runtime, input)
}
if spec.validateCreateInput != nil {
if err := spec.validateCreateInput(runtime, input); err != nil {
return nil, err
}
}
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
@@ -392,6 +381,9 @@ var pivotSpec = objectCRUDSpec{
},
createWarn: pivotPlacementWarn,
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
input["target_position"] = v
}
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return
@@ -399,26 +391,10 @@ var pivotSpec = objectCRUDSpec{
if v := strings.TrimSpace(rt.Str("source")); v != "" {
props["source"] = v
}
// --target-position 与 --range 都映射到 properties.range
// --target-position 优先,未给(或为默认值 A1时回落到 --range。
// 互斥校验在 validateCreateInput 里做。
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
props["range"] = v
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
if v := strings.TrimSpace(rt.Str("range")); v != "" {
props["range"] = v
}
},
// --target-position 与 --range 落到同一 wire 字段properties.range
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
// 的处理方式CLI 端直接拒绝(优于静默丢弃其一)。
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
pos := strings.TrimSpace(rt.Str("target-position"))
rng := strings.TrimSpace(rt.Str("range"))
if pos != "" && pos != "A1" && rng != "" {
return common.FlagErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
}
return nil
},
}
var PivotCreate = newObjectCreateShortcut(pivotSpec)
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)

View File

@@ -137,24 +137,25 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
// covered separately in the +pivot-create empty-selector / mutex
// tests below.
{
name: "+pivot-create with placement / source / target-position flags",
name: "+pivot-create with placement / source / range flags",
sc: PivotCreate,
args: []string{
"--url", testURL, "--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--range", "F1",
"--target-position", "B5",
},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"target_position": "B5",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
"source": "Sheet1!A1:F1000",
// --target-position 映射到 properties.range。
"range": "B5",
"range": "F1",
},
},
},
@@ -506,55 +507,6 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
})
}
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
// and --range cannot both be set" guardrail on +pivot-create. They map to
// the same wire field (properties.range), so two non-default values are
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
// --target-sheet-name mutex). --target-position=A1 is the documented default
// and is treated as "not set" — pairing it with --range still works.
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
t.Parallel()
t.Run("both non-default values rejected", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--target-position", "B5",
"--range", "F1",
})
if err == nil {
t.Fatalf("expected CLI to reject --target-position with --range; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "mutually exclusive") {
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
}
if !strings.Contains(combined, "--target-position") || !strings.Contains(combined, "--range") {
t.Errorf("expected error to quote both --target-position and --range; got=%s|%v", stderr, err)
}
})
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--target-position", "A1",
"--range", "F1",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
props, _ := input["properties"].(map[string]interface{})
if got, _ := props["range"].(string); got != "F1" {
t.Errorf("properties.range = %q, want %q", got, "F1")
}
})
}
// TestPivotCreate_SchemaValidates exercises the schema-driven
// validator wired into objectCreateInput. The pivot create schema
// doesn't constrain rows/columns/values to be present (the backend

View File

@@ -5,6 +5,8 @@ package sheets
import (
"context"
"encoding/csv"
"regexp"
"strconv"
"strings"
@@ -162,7 +164,12 @@ var CsvGet = common.Shortcut{
if err != nil {
return err
}
if !runtime.Bool("include-row-prefix") {
switch {
case runtime.Bool("rows-json"):
// --rows-json reshapes the CSV response into structured rows
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
case !runtime.Bool("include-row-prefix"):
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
@@ -212,6 +219,141 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
return m
}
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
// the tool prepends to the first physical line of each logical CSV record.
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
//
// {
// "range": "A1:K3380",
// "current_region": "...", // passthrough, if the tool returned it
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
// }
//
// Every logical row is emitted, including the first — no row is assumed to be a
// header, since sheet data is not always tabular. Each cell is keyed by its
// column letter (from the tool's col_indices when present, else derived from the
// requested range's start column). On any parsing trouble it returns the
// original output unchanged.
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
m, ok := out.(map[string]interface{})
if !ok {
return out
}
csvStr, ok := m["annotated_csv"].(string)
if !ok {
return out
}
// Group physical lines into logical records by [row=N] boundaries; lines
// without a prefix are embedded-newline continuations of the current record.
type logicalRow struct {
num int
text string
}
var groups []logicalRow
for _, line := range strings.Split(csvStr, "\n") {
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
n, _ := strconv.Atoi(mm[1])
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
} else if len(groups) > 0 {
groups[len(groups)-1].text += "\n" + line
}
}
if len(groups) == 0 {
return out
}
// Parse every logical row; widest row sets the column count. No row is
// singled out as a header — that would assume the data is tabular, which it
// often is not. The model reads row 1 like any other row and decides for
// itself whether it is a header.
parsed := make([][]string, len(groups))
maxCols := 0
for i, g := range groups {
parsed[i] = parseCSVRecord(g.text)
if len(parsed[i]) > maxCols {
maxCols = len(parsed[i])
}
}
if maxCols == 0 {
return out
}
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
// length == col_count); otherwise derive from the requested range's start col.
letters := coerceStringSlice(m["col_indices"])
if len(letters) < maxCols {
start := csvStartColIndex(requestedRange)
letters = make([]string, maxCols)
for j := 0; j < maxCols; j++ {
letters[j] = csvColLetter(start + j)
}
}
rows := make([]map[string]interface{}, 0, len(groups))
for i := range groups {
fields := parsed[i]
values := make(map[string]interface{}, len(letters))
for j := range letters {
v := ""
if j < len(fields) {
v = fields[j]
}
values[letters[j]] = v
}
rows = append(rows, map[string]interface{}{
"row_number": groups[i].num,
"values": values,
})
}
result := map[string]interface{}{}
for k, v := range m {
result[k] = v
}
result["range"] = requestedRange
result["rows"] = rows
// Surface the backend's "数据没读全" signal structurally instead of leaving it
// buried in warning_message prose. The tool flags it when current_region (the
// true data extent) reaches past actual_range (what was actually read) — the
// single most important anti-under-read hint. Mirror that same comparison
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
// model gets the real data range as a first-class field, never having to
// parse it out of prose.
if cr, _ := m["current_region"].(string); cr != "" {
ar, _ := m["actual_range"].(string)
regionEnd := a1EndRow(cr)
readEnd := a1EndRow(ar)
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
result["data_not_fully_read"] = map[string]interface{}{
"read_through_row": readEnd,
"data_extends_through_row": regionEnd,
"unread_rows": regionEnd - readEnd,
"reread_range": cr,
}
}
}
// Drop the fields whose information rows-json fully carries elsewhere:
// - annotated_csv / row_indices / col_indices → reconstructed into
// columns + rows (with integer row_number), losslessly.
// - warning_message → its two halves are both handled: the static
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
// and the dynamic "数据没读全" half is now the structured
// data_not_fully_read field above. (Confirmed against the backend's
// get-range-as-csv.ts — warning_message has no other content.)
delete(result, "annotated_csv")
delete(result, "row_indices")
delete(result, "col_indices")
delete(result, "warning_message")
return result
}
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
func a1EndRow(rng string) int {
@@ -235,6 +377,89 @@ func a1EndRow(rng string) int {
return n
}
// parseCSVRecord parses a single logical CSV record (which may span multiple
// physical lines via quoted embedded newlines) into its fields. An empty record
// yields no fields; a malformed record falls back to a naive comma split so a
// stray quote never drops a whole row.
func parseCSVRecord(text string) []string {
if strings.TrimSpace(text) == "" {
return nil
}
r := csv.NewReader(strings.NewReader(text))
r.FieldsPerRecord = -1
fields, err := r.Read()
if err != nil {
return strings.Split(text, ",")
}
return fields
}
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
// of strings (the shape of the tool's col_indices), else nil.
func coerceStringSlice(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
s, ok := e.(string)
if !ok {
return nil
}
out = append(out, s)
}
return out
}
// csvStartColIndex returns the 0-based column index of a range's start column,
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
func csvStartColIndex(rng string) int {
rng = strings.TrimSpace(rng)
if i := strings.LastIndex(rng, "!"); i >= 0 {
rng = rng[i+1:]
}
var letters strings.Builder
for _, c := range rng {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
letters.WriteRune(c)
continue
}
break
}
if letters.Len() == 0 {
return 0
}
return csvColToIndex(letters.String())
}
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
// "AA"→26). Non-letter input → -1.
func csvColToIndex(s string) int {
n := 0
for _, c := range strings.ToUpper(s) {
if c < 'A' || c > 'Z' {
break
}
n = n*26 + int(c-'A'+1)
}
return n - 1
}
// csvColLetter converts a 0-based column index back to its letter (0→"A",
// 25→"Z", 26→"AA"). Negative input → "".
func csvColLetter(idx int) string {
if idx < 0 {
return ""
}
var b []byte
for idx >= 0 {
b = append([]byte{byte('A' + idx%26)}, b...)
idx = idx/26 - 1
}
return string(b)
}
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
// dropdown configuration on a range. Aligned with its sibling +cells-get
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range

View File

@@ -63,6 +63,20 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"value_render_option": "formatted_value",
},
},
{
// --rows-json is post-processing on +csv-get's response; it must
// NOT leak into the get_range_as_csv input.
name: "+csv-get --rows-json builds the same input (flag is post-process)",
sc: CsvGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
toolName: "get_range_as_csv",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:C10",
"max_rows": float64(unboundedReadLimit),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -165,3 +179,113 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
t.Errorf("other field corrupted: %v", out["other"])
}
}
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
// emitted (no header singled out), integer row_number, column-letter keyed
// values, embedded newlines inside quoted fields, and current_region passthrough.
func TestAssembleRowsJSON(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
"current_region": "A1:C3",
"col_indices": []interface{}{"A", "B", "C"},
"row_indices": []interface{}{1, 2, 3},
"warning_message": "①定位行号…②定位列字母…",
}
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
if !ok {
t.Fatalf("assembleRowsJSON did not return a map")
}
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
// indices → rows; warning_message → moot static nag + structured
// data_not_fully_read). Unrelated metadata like current_region is preserved.
if _, exists := out["annotated_csv"]; exists {
t.Errorf("annotated_csv should be dropped")
}
if _, exists := out["col_indices"]; exists {
t.Errorf("col_indices should be dropped")
}
if _, exists := out["warning_message"]; exists {
t.Errorf("warning_message should be dropped in rows-json mode")
}
if _, exists := out["columns"]; exists {
t.Errorf("columns field should not exist (no header assumption)")
}
if out["current_region"] != "A1:C3" {
t.Errorf("current_region passthrough lost: %v", out["current_region"])
}
rows, _ := out["rows"].([]map[string]interface{})
if len(rows) != 3 {
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
}
// Row 1 is emitted as a normal row, not consumed as a header.
if rows[0]["row_number"].(int) != 1 {
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
t.Errorf("row 1 values wrong: %+v", v)
}
// Row 2 keeps its embedded newline inside a single cell.
v1 := rows[1]["values"].(map[string]interface{})
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
}
}
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
// range start when the tool omits col_indices (e.g. a C-anchored read).
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
}
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
rows := out["rows"].([]map[string]interface{})
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d", len(rows))
}
if rows[0]["row_number"].(int) != 5 {
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
t.Errorf("derived-letter values wrong: %+v", v)
}
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
t.Errorf("row 6 values wrong: %+v", v)
}
}
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
// when current_region extends past actual_range, rows-json surfaces the true data
// range as a first-class field (mirroring the backend's prose warning).
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
t.Parallel()
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
in := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D4",
}
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
hint, ok := out["data_not_fully_read"].(map[string]interface{})
if !ok {
t.Fatalf("data_not_fully_read missing; out=%+v", out)
}
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
t.Errorf("data_not_fully_read wrong: %+v", hint)
}
// Fully-read case: no hint emitted.
in2 := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D2",
}
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
if _, exists := out2["data_not_fully_read"]; exists {
t.Errorf("data_not_fully_read should be absent when fully read")
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
// --output-path, +workbook-export delegates to the shared drive export core
// with OutputDir="" so it creates + polls the export task and returns the ready
// file token without writing a local file (downloaded=false).
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_export"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"job_status": float64(0),
"file_token": "ftk_xlsx",
"file_name": "report.xlsx",
"file_size": float64(2048),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
}, stubs...)
if err != nil {
t.Fatalf("export-only execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if env.Data["ready"] != true {
t.Errorf("ready = %v, want true", env.Data["ready"])
}
if env.Data["downloaded"] != false {
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
}
if env.Data["file_token"] != "ftk_xlsx" {
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
}
if env.Data["doc_type"] != "sheet" {
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
}
}

View File

@@ -1,135 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// chdirTemp switches into a fresh temp dir for the duration of the test and
// restores the original cwd afterwards. +workbook-import is the first sheets
// shortcut that stat()s a real local file, so these tests need a working dir.
func chdirTemp(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
}
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
// shared drive import core and hard-codes the import target type to "sheet".
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
var createBody map[string]interface{}
for _, c := range calls {
cm, _ := c.(map[string]interface{})
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
createBody, _ = cm["body"].(map[string]interface{})
}
}
if createBody == nil {
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
}
if createBody["type"] != "sheet" {
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
}
if createBody["file_extension"] != "xlsx" {
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
}
}
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
// front and the error surfaces through the normal envelope/err path.
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
}
}
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
// flow against stubs and asserts the resulting URL is a /sheets/ link.
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_import_media"},
},
},
{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_sheet"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"token": "shtcn_imported",
"type": "sheet",
"job_status": float64(0),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
if err != nil {
t.Fatalf("import execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("execute output has no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
t.Errorf("imported url = %q, want a /sheets/ link", url)
}
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
t.Errorf("token = %q, want shtcn_imported", tok)
}
}

View File

@@ -4,10 +4,14 @@
package sheets
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -141,28 +145,6 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
"tab_color": "",
},
},
{
name: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "show_gridline",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "hide_gridline",
"sheet_id": testSheetID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -306,7 +288,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
}
c := calls[0].(map[string]interface{})
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
@@ -318,11 +300,12 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
}
})
t.Run("with values → 2-step plan", func(t *testing.T) {
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
"--headers", `["Name","Score"]`,
"--values", `[["alice",95],["bob",88]]`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
@@ -334,88 +317,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
body, _ := fill["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:B3" {
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
}
})
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
}
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
if len(cells) != 2 {
t.Fatalf("cells rows = %#v, want 2", input["cells"])
}
headerRow, _ := cells[0].([]interface{})
firstHeader, _ := headerRow[0].(map[string]interface{})
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
t.Errorf("first header style = %#v, want bold + background", firstStyle)
}
secondHeader, _ := headerRow[1].(map[string]interface{})
if secondHeader["border_styles"] == nil {
t.Errorf("second header missing border_styles: %#v", secondHeader)
}
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
if secondStyle["number_format"] != "0" {
t.Errorf("second header number_format = %#v, want 0", secondStyle)
}
dataRow, _ := cells[1].([]interface{})
firstData, _ := dataRow[0].(map[string]interface{})
if _, ok := firstData["cell_styles"]; ok {
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
}
secondData, _ := dataRow[1].(map[string]interface{})
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
if secondDataStyle["font_color"] != "#0f7b0f" {
t.Errorf("second data style = %#v, want font color", secondDataStyle)
}
})
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
})
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
raw, _ := json.Marshal(input["cells"])
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
}
})
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "X",
"--values", `[["a","b"]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
})
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
b1, _ := row0[1].(map[string]interface{})
b1s, _ := b1["cell_styles"].(map[string]interface{})
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
}
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
a1, _ := row0[0].(map[string]interface{})
a1s, _ := a1["cell_styles"].(map[string]interface{})
if a1s["font_color"] != nil {
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
}
})
}
@@ -428,18 +330,8 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
args []string
want string
}{
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
@@ -452,21 +344,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
}
}
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
// shared drive export core: a single create-task POST (poll + download are
// described inline rather than as separate api entries).
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
// --output-path. The order should be: POST → GET (poll) → optional GET
// (download).
func TestWorkbookExport_DryRun(t *testing.T) {
t.Parallel()
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
}
create := calls[0].(map[string]interface{})
if create["url"] != "/open-apis/drive/v1/export_tasks" {
t.Errorf("url = %v", create["url"])
t.Errorf("first url = %v", create["url"])
}
body, _ := create["body"].(map[string]interface{})
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
@@ -474,18 +366,22 @@ func TestWorkbookExport_DryRun(t *testing.T) {
}
})
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
"--output-path", "/tmp/out.csv",
})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1", len(calls))
if len(calls) != 3 {
t.Fatalf("api calls = %d, want 3", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
if body["sub_id"] != "sh1" {
t.Errorf("csv export missing sub_id: %#v", body)
}
dl := calls[2].(map[string]interface{})
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
t.Errorf("download url = %v", dl["url"])
}
})
@@ -500,6 +396,92 @@ func TestWorkbookExport_DryRun(t *testing.T) {
})
}
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
t.Parallel()
t.Run("preserves typed request errors", func(t *testing.T) {
t.Parallel()
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
got := sheetsDownloadRequestError(in)
if got != in {
t.Fatalf("typed error was not preserved: got %T %v", got, got)
}
})
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
t.Parallel()
got := sheetsDownloadRequestError(errors.New("dial refused"))
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T %v", got, got)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
})
tests := []struct {
name string
status int
wantCategory errs.Category
wantSubtype errs.Subtype
wantRetryable bool
}{
{
name: "5xx is retryable network server error",
status: http.StatusBadGateway,
wantCategory: errs.CategoryNetwork,
wantSubtype: errs.SubtypeNetworkServer,
wantRetryable: true,
},
{
name: "404 is API not found",
status: http.StatusNotFound,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeNotFound,
},
{
name: "429 is retryable API rate limit",
status: http.StatusTooManyRequests,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeRateLimit,
wantRetryable: true,
},
{
name: "other 4xx is API unknown",
status: http.StatusForbidden,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
StatusCode: tt.status,
RawBody: []byte("body"),
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
})
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T %v", got, got)
}
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
}
if p.Code != tt.status {
t.Fatalf("code = %d, want %d", p.Code, tt.status)
}
if p.LogID != "log123" {
t.Fatalf("log_id = %q, want log123", p.LogID)
}
if p.Retryable != tt.wantRetryable {
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
}
})
}
}
// assertInputEquals compares the decoded tool input map against the wanted
// fields. Extra fields in `got` are allowed (defaults, optional fields);
// every key in `want` must match exactly.

View File

@@ -197,12 +197,12 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
return input, nil
}
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
// plain values. Use +cells-set for anything richer (formula / style / note).
var CsvPut = common.Shortcut{
Service: "sheets",
Command: "+csv-put",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},

View File

@@ -4,7 +4,6 @@
package sheets
import (
"fmt"
"strings"
"testing"
@@ -428,44 +427,6 @@ func TestCellsSet_RequiresJSONArray(t *testing.T) {
}
}
// TestCellsSet_RejectsUnsupportedMentionType pins the mention_type enum in
// data/flag-schemas.json (synced from the upstream tool schema): a rich_text
// mention whose mention_type is outside MENTION_FILE_TYPE (here 6 = cloud
// shared folder) is rejected by the schema validator at flag-parse time,
// before it can reach the server and blow up pb serialization
// ("mentionFileInfo.fileType: enum value expected").
func TestCellsSet_RejectsUnsupportedMentionType(t *testing.T) {
t.Parallel()
cells := `[[{"rich_text":[{"type":"mention","text":"x","mention_type":6,"mention_token":"t"}]}]]`
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells, "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "mention_type") || !strings.Contains(combined, "not in enum") {
t.Errorf("expected mention_type enum guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestCellsSet_AllowsValidMentionTypes confirms the guard lets through a
// user @mention (mention_type 0) and a render-supported file type (22 = DOCX).
func TestCellsSet_AllowsValidMentionTypes(t *testing.T) {
t.Parallel()
for _, mt := range []int{0, 22} {
cells := fmt.Sprintf(`[[{"rich_text":[{"type":"mention","text":"x","mention_type":%d,"mention_token":"t"}]}]]`, mt)
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells, "--dry-run",
})
if err != nil {
t.Errorf("mention_type %d: unexpected error: stdout=%s stderr=%s err=%v", mt, stdout, stderr, err)
}
}
}
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
// rendered, including the parent_type=sheet_image upload metadata.
func TestCellsSetImage_DryRun(t *testing.T) {

View File

@@ -38,11 +38,8 @@ func shortcutList() []common.Shortcut {
SheetHide,
SheetUnhide,
SheetSetTabColor,
SheetShowGridline,
SheetHideGridline,
WorkbookCreate,
WorkbookExport,
WorkbookImport,
// lark_sheet_sheet_structure
SheetInfo,
@@ -59,7 +56,6 @@ func shortcutList() []common.Shortcut {
CellsGet,
CsvGet,
DropdownGet,
TableGet,
// lark_sheet_search_replace
CellsSearch,
@@ -71,7 +67,6 @@ func shortcutList() []common.Shortcut {
CellsSetImage,
CsvPut,
DropdownSet,
TablePut,
// lark_sheet_range_operations
CellsClear,
@@ -108,10 +103,5 @@ func shortcutList() []common.Shortcut {
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
// lark_sheet_history
HistoryList,
HistoryRevert,
HistoryRevertStatus,
}
}

View File

@@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut {
VCRecording,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingListActive,
VCMeetingEvents,
}
}

View File

@@ -48,7 +48,7 @@ var VCMeetingEvents = common.Shortcut{
Description: "List bot meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
@@ -156,6 +156,9 @@ func validateMeetingEventsMeetingID(meetingID string) error {
if meetingID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id is required").WithParam("--meeting-id")
}
if validMeetingNumber(meetingID) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a long meeting_id, not a 9-digit meeting number; use +meeting-join or +meeting-list-active to get meeting_id").WithParam("--meeting-id")
}
value, err := strconv.ParseInt(meetingID, 10, 64)
if err != nil || value <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a positive integer, got %q", meetingID).WithParam("--meeting-id")

View File

@@ -262,6 +262,26 @@ func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
}
}
func TestMeetingEvents_Validation_RejectsMeetingNumber(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "732067044")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for 9-digit meeting number")
}
if !strings.Contains(err.Error(), "not a 9-digit meeting number") {
t.Fatalf("unexpected error: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--meeting-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-id")
}
}
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
@@ -818,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -28,7 +28,7 @@ var VCMeetingJoin = common.Shortcut{
Description: "Join a meeting by meeting number (bot join)",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},

View File

@@ -20,7 +20,7 @@ var VCMeetingLeave = common.Shortcut{
Description: "Leave a meeting by meeting ID",
Risk: "write",
Scopes: []string{"vc:meeting.bot.join:write"},
AuthTypes: []string{"user"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const vcMeetingListActiveAPIPath = "/open-apis/vc/v1/bots/user_active_meeting"
// VCMeetingListActive lists meetings the current or target user is actively in.
var VCMeetingListActive = common.Shortcut{
Service: "vc",
Command: "+meeting-list-active",
Description: "List active meetings for the current identity or target user",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id", Desc: "target user ID when using bot identity"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMeetingListActiveUserID(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params, err := buildMeetingListActiveParams(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dryRun := common.NewDryRunAPI().GET(vcMeetingListActiveAPIPath)
if len(params) > 0 {
dryRun.Params(params)
}
return dryRun
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params, err := buildMeetingListActiveParams(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingListActiveAPIPath, params, nil)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
meetings := common.GetSlice(data, "meetings")
runtime.OutFormat(data, &output.Meta{Count: len(meetings)}, func(w io.Writer) {
if len(meetings) == 0 {
fmt.Fprintln(w, "No active meetings.")
return
}
displayedMeetings := 0
for _, raw := range meetings {
meeting, _ := raw.(map[string]interface{})
if meeting == nil {
continue
}
if displayedMeetings > 0 {
fmt.Fprintln(w)
}
displayedMeetings++
title := common.GetString(meeting, "meeting_title")
if title == "" {
title = "Untitled meeting"
}
fmt.Fprintf(w, "%s\n", title)
if id := common.GetString(meeting, "meeting_id"); id != "" {
fmt.Fprintf(w, " Meeting ID: %s\n", id)
}
if no := common.GetString(meeting, "meeting_no"); no != "" {
fmt.Fprintf(w, " Meeting No: %s\n", no)
}
}
if displayedMeetings > 1 {
fmt.Fprintln(w)
fmt.Fprintln(w, "Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.")
}
})
return nil
},
}
// validateMeetingListActiveUserID validates the target user only for bot identity.
func validateMeetingListActiveUserID(runtime *common.RuntimeContext) error {
if !runtime.IsBot() {
return nil
}
userID := strings.TrimSpace(runtime.Str("user-id"))
if userID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required when --as bot").WithParam("--user-id")
}
if _, err := common.ValidateUserIDTyped("--user-id", userID); err != nil {
return err
}
return nil
}
// buildMeetingListActiveParams builds the query params for active meeting lookup.
func buildMeetingListActiveParams(runtime *common.RuntimeContext) (map[string]interface{}, error) {
if err := validateMeetingListActiveUserID(runtime); err != nil {
return nil, err
}
params := map[string]interface{}{}
if runtime.IsBot() {
userID := strings.TrimSpace(runtime.Str("user-id"))
params["user_id"] = userID
}
return params, nil
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
@@ -15,6 +16,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -589,6 +591,335 @@ func TestMeetingLeave_Execute_APIError(t *testing.T) {
}
}
func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/vc/v1/bots/user_active_meeting") {
t.Errorf("dry-run should include API path, got: %s", out)
}
if strings.Contains(out, "user_id") {
t.Errorf("user identity should not send user_id by default, got: %s", out)
}
}
func TestMeetingListActive_ScopeMatchesEventReadPermission(t *testing.T) {
if len(VCMeetingListActive.Scopes) != 1 || VCMeetingListActive.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Fatalf("scopes = %#v, want [vc:meeting.meetingevent:read]", VCMeetingListActive.Scopes)
}
}
func TestMeetingListActive_DryRun_UserIdentityIgnoresUserID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "user", "--user-id", "not-open-id",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(stdout.String(), "user_id") {
t.Errorf("user identity should not send user_id, got: %s", stdout.String())
}
}
func TestMeetingListActive_Execute_UserIdentityIgnoresInvalidUserID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var gotUserID string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
OnMatch: func(req *http.Request) {
gotUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meetings": []interface{}{}},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "user", "--user-id", "not-open-id", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotUserID != "" {
t.Fatalf("user identity should not send user_id, got %q", gotUserID)
}
}
func TestMeetingListActive_Validate_BotRequiresUserID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{"+meeting-list-active", "--as", "bot"}, f, nil)
if err == nil {
t.Fatal("expected error when --as bot omits --user-id")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_Validate_UserIDOpenIDFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--as", "bot", "--user-id", "300",
}, f, nil)
if err == nil {
t.Fatal("expected error for non-open_id user-id")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_Execute_BotPassesUserID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var gotUserID string
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
OnMatch: func(req *http.Request) {
gotUserID = req.URL.Query().Get("user_id")
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--user-id", "ou_300",
"--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotUserID != "ou_300" {
t.Fatalf("user_id query = %q, want ou_300", gotUserID)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse stdout: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("meetings = %d, want 1 (envelope: %s)", len(meetings), stdout.String())
}
}
func TestMeetingListActive_DryRun_BotValidationErrorEnvelope(t *testing.T) {
cmd := &cobra.Command{Use: "+meeting-list-active"}
cmd.Flags().String("user-id", "", "")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
dry := VCMeetingListActive.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("failed to marshal dry-run output: %v", err)
}
got := string(raw)
if !strings.Contains(got, "--user-id") {
t.Fatalf("dry-run error = %q, want user-id validation", got)
}
}
func TestMeetingListActive_DryRun_BotSendsUserID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--dry-run", "--as", "bot", "--user-id", "ou_300",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "user_id") || !strings.Contains(stdout.String(), "ou_300") {
t.Fatalf("dry-run should include user_id=ou_300, got: %s", stdout.String())
}
}
func TestMeetingListActive_Execute_ValidationError(t *testing.T) {
cmd := &cobra.Command{Use: "+meeting-list-active"}
cmd.Flags().String("user-id", "", "")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
err := VCMeetingListActive.Execute(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
assertMeetingListActiveUserIDValidationError(t, err)
}
func TestMeetingListActive_ExecutePretty_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "No active meetings.") {
t.Fatalf("pretty output = %q, want empty-state message", stdout.String())
}
}
func TestMeetingListActive_ExecutePretty_SingleMeetingNoSelectionPrompt(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"Standup", "Meeting ID: 9001", "Meeting No: 123456789"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
if strings.Contains(out, "Multiple active meetings found") {
t.Fatalf("single meeting should not show selection prompt: %s", out)
}
}
func TestMeetingListActive_ExecutePretty_MultipleMeetings(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"meetings": []interface{}{
map[string]interface{}{
"meeting_id": "9001",
"meeting_no": "123456789",
"meeting_title": "Standup",
},
"ignored",
map[string]interface{}{
"meeting_id": "9002",
"meeting_no": "987654321",
"meeting_title": "Planning",
},
map[string]interface{}{
"meeting_id": "9003",
},
},
},
},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "pretty", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Standup",
"Meeting ID: 9001",
"Meeting No: 123456789",
"Planning",
"Meeting ID: 9002",
"Meeting No: 987654321",
"Untitled meeting",
"Meeting ID: 9003",
"Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q: %s", want, out)
}
}
}
func TestMeetingListActive_Execute_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/bots/user_active_meeting",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCMeetingListActive, []string{
"+meeting-list-active", "--format", "json", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error")
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryAuthorization {
t.Fatalf("error problem = (%+v, %t), want authorization problem", p, ok)
} else if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("error subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
} else if p.Code != 121005 {
t.Fatalf("error code = %d, want 121005", p.Code)
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
}
func assertMeetingListActiveUserIDValidationError(t *testing.T, err error) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--user-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--user-id")
}
}
// ---------------------------------------------------------------------------
// Typed error lock assertions
// ---------------------------------------------------------------------------

View File

@@ -5,7 +5,7 @@ description: "飞书云文档Docx / Wiki 文档v2 API读取和编辑
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help"
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs resource-download --help; lark-cli docs resource-update --help; lark-cli docs resource-delete --help"
---
# docs (v2)
@@ -45,6 +45,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs resource-download/resource-update/resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
@@ -71,6 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
| [`resource-download` / `resource-update` / `resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
## 不在本 Skill 范围

View File

@@ -23,7 +23,7 @@ lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<titl
# 创建到个人知识库XML
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
# 仅当用户明确要求时才使用 Markdown
# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始
lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
```
@@ -72,7 +72,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
## 最佳实践
- 文档标题从内容中自动提取XML `<title>`Markdown `#`),不要在内容开头重复写标题
- 文档标题从内容中自动提取XML 使用 `<title>`Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
- **创建较长的文档时只建骨架**`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档

View File

@@ -124,6 +124,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
- `<img>` / `<source>``url` 时,直接用该 URL 下载即可(普通 HTTP GET无需走 shortcut。
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut`docs +media-download --token <token> --output ./downloaded_media`
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs resource-download/resource-update/resource-delete --type cover`
## 嵌入电子表格 / 多维表格
@@ -134,4 +135,5 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
- [lark-doc-create](lark-doc-create.md) — 创建文档
- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
- [lark-doc-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图

View File

@@ -2,6 +2,10 @@
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
## 创建文档标题
使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`
## 转义规则
> **⚠️ 当文本中包含以下字符且不想触发 Markdown 语法时**,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
@@ -73,4 +77,4 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
## 参考
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范

View File

@@ -0,0 +1,70 @@
# docs resource-*Docx 封面图资源)
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
Docx 封面图不是正文里的 `<img token="...">` 素材块。读取、更新、删除文档封面图时,使用 `docs resource-download/resource-update/resource-delete --type cover`,不要使用 `+media-insert``+media-download --token <cover.token>` 让用户手动拼步骤。
## 选择规则
- 用户要下载文档封面图:`docs resource-download --type cover`
- 用户要设置/替换文档封面图:`docs resource-update --type cover`
- 用户要删除文档封面图:`docs resource-delete --type cover`
- 用户要下载正文图片、附件、画板缩略图:继续使用 [`docs +media-download`](lark-doc-media-download.md)
## 命令
```bash
# 下载封面图。CLI 会先读取 document.cover.token再下载图片内容并保存到本地。
lark-cli docs resource-download --doc doxcnXXX --type cover --output ./cover
# 使用本地文件更新封面图。
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png
# 使用剪切板图片更新封面图。
lark-cli docs resource-update --doc doxcnXXX --type cover --from-clipboard
# 使用 HTTPS URL 更新封面图。CLI 会先下载 URL 内容,再上传并写入 cover.token。
lark-cli docs resource-update --doc doxcnXXX --type cover --url "https://example.com/cover.png"
# 可选:设置封面图裁切偏移。
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png --offset-ratio-x 0.2 --offset-ratio-y 0.8
# 删除封面图;当文档本来没有封面图时也成功返回。
lark-cli docs resource-delete --doc doxcnXXX --type cover
```
## 参数
| 命令 | 参数 | 必填 | 说明 |
|------|------|------|------|
| all | `--doc <id>` | 是 | 文档 ID、docx URL或可解析为 docx 的 wiki URL |
| all | `--type cover` | 否 | 当前只支持 `cover`;默认值也是 `cover` |
| download | `--output <path>` | 是 | 本地保存路径;不带扩展名会根据响应类型自动补全 |
| download | `--overwrite` | 否 | 覆盖已存在的输出文件 |
| update | `--file <path>` | 三选一 | 磁盘上的真实图片文件;大于 20MiB 自动使用分片上传 |
| update | `--from-clipboard` | 三选一 | 从系统剪切板读取图片 |
| update | `--url <https-url>` | 三选一 | 从 HTTPS URL 下载图片后上传 |
| update | `--offset-ratio-x <number>` | 否 | 视图相对原图中心的横向偏移比例:水平偏移 px / 原图宽度 px0 为居中,正数向右,负数向左 |
| update | `--offset-ratio-y <number>` | 否 | 视图相对原图中心的纵向偏移比例:垂直偏移 px / 原图高度 px0 为居中,正数向上,负数向下 |
## 输出契约
- `resource-download` 成功时 stdout JSON 的 `data` 包含 `document_id``type``saved_path``size_bytes``content_type``cover.token`。如果文档没有封面图,命令失败退出,错误包含 `document has no cover` 和脱敏 `document_id`,不会创建输出文件。
- `resource-update` 成功时 stdout JSON 的 `data` 包含完整 `file_token``cover.token`stderr 只打印脱敏 token。
- `resource-delete` 成功时 stdout JSON 的 `data.deleted` 表示本次是否真的发起删除,`data.already_empty` 表示删除前是否没有封面图。空封面图是幂等成功,不报错。
## URL 来源安全边界
`resource-update --url` 只用于下载公开 HTTPS 图片:
- 只允许 `https://`,拒绝 HTTP、空 host 和 URL userinfo。
- 拒绝解析到 private、loopback、link-local、multicast、unspecified 地址的 host。
- 最多跟随 3 次跳转,每次跳转都重新校验 URL。
- 响应 `Content-Type` 只允许 `image/png``image/jpeg``image/gif``image/webp`
- 响应体最大 20MiB。
## 参考
- [lark-doc-media-download](lark-doc-media-download.md) — 下载正文素材或画板缩略图
- [lark-doc-media-insert](lark-doc-media-insert.md) — 在正文插入图片/文件
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -113,6 +113,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
--content '<p>替换后的段落内容</p>'
```
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID不要复用旧 ID。
### block_delete — 删除指定 block
```bash
@@ -234,6 +236,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
- **block_replace 后重新获取 ID**`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
1. 用 `block_insert_after` 在目标位置插入新的富文本结构

View File

@@ -10,6 +10,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
## 创建文档标题
使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题</title>`,且每篇文档只写一个 `<title>`
## 容器标签
|标签|说明|关键属性|
|-|-|-|
@@ -77,6 +81,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
</ul>
```
## 代码块
- 代码块必须写成 `<pre lang="xxx" caption="可选说明"><code>代码内容</code></pre>`。
- 不要将代码文本直接放在 `<pre>` 下;应放在内层 `<code>` 中。
## 用户名写入规则

View File

@@ -19,7 +19,7 @@ metadata:
## 快速决策
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
@@ -110,7 +110,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|----------|
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象支持 `--edited-since``--mine``--doc-types` 等扁平 flag。 |
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象支持 `--edited-since``--created-by-me``--mine``--doc-types` 等扁平 flag;区分 original creator 与 owner 语义。 |
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |

View File

@@ -76,6 +76,14 @@ lark-cli drive +export \
--file-extension base \
--output-dir ./exports
# 导出多维表格结构为 .base 快照(仅导出表结构,不导出记录数据)
lark-cli drive +export \
--token "<BITABLE_TOKEN>" \
--doc-type bitable \
--file-extension base \
--only-schema \
--output-dir ./exports
# 允许覆盖已存在文件
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
@@ -92,6 +100,7 @@ lark-cli drive +export \
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
| `--overwrite` | 否 | 覆盖已存在文件 |
@@ -100,6 +109,7 @@ lark-cli drive +export \
- `markdown` 只支持 `docx`
- `base` 只支持 `bitable`
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
- `pptx` 只支持 `slides`
- `slides` 支持导出为 `pptx` / `pdf`
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`

View File

@@ -9,6 +9,11 @@
> 这是 Drive 导入场景,不是 `lark-base` 的建表 / 写记录场景。
> 只有导入完成并拿到新文档的 `token` / `url` 后,后续字段、记录、视图等表内操作才切换到 `lark-cli base +...`。
## 导入后标题确认
> [!IMPORTANT]
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
## 命令
```bash

View File

@@ -7,10 +7,10 @@
核心特性:
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--created-by-me``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 额外暴露了 4 个"我"维度:`my_edit_time`(我编辑过)、`my_comment_time`(我评论过)、`open_time`(我打开过)、`create_time`(文档创建时间)——直接对应用户自然语言里的"最近我编辑过的"、"我评论过的"等表达
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap并在 stderr 打出提示
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`不必再先去查 contact注意 `creator_ids` 服务端按 **owner / 文档归属人** 语义匹配,不是“最初创建人”,详见下文「身份维度」)
- `--created-by-me` 一键从当前登录用户的 open_id 填 `original_creator_ids`匹配“我最初创建的”;`--mine` 仍填 `creator_ids`,匹配 owner / 文档归属人
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间(云盘/云存储)对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
@@ -21,18 +21,19 @@
> 错误:`lark-cli drive +search 方案`
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --mine --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
| 最近一个月我编辑过 且 我评论过的 | `lark-cli drive +search --query "" --edited-since 1m --commented-since 1m` |
| 最近一周我打开过的表格 | `lark-cli drive +search --query "" --opened-since 7d --doc-types sheet` |
| 我 owner 的所有文档owner 语义,非"我最初创建" | `lark-cli drive +search --query "" --mine` |
| 我最初创建、后来转给王五 owner 的文档 | `lark-cli drive +search --query "" --created-by-me --creator-ids ou_wangwu` |
| 我 owner、30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算;`--mine` 是 owner`--created-*` 才是文档创建时间) | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 owner、2026 年 3 月创建的文档精确日历月同上owner + 创建时间窗两个维度) | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 关键词"预算",最近一周我打开过,按编辑时间降序 | `lark-cli drive +search --query 预算 --opened-since 7d --sort edit_time` |
@@ -77,7 +78,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
对"所有文档"、"按类型分类统计"、"最近更新过"这类请求,不要只跑一次搜索后直接回答。标准流程:
1. 先把自然语言拆成过滤条件:所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
1. 先把自然语言拆成过滤条件:原始创建者(`--created-by-me` / `--original-creator-ids`)、所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
2. 没有真实业务关键词时保持 `--query ""`;不要把"所有文档"、"统计"、"最近更新"放进 query。
3. 检查返回结果的 `doc_type` / `result_meta.doc_types`、创建/编辑时间和 URL/token 是否与过滤目标一致;明显不符合的结果不要计入答案。
4. 用户要求"所有 / 全量 / 统计"时按 `has_more` 翻页并累积去重;不要只用第一页推断总量。返回体里的 `total` 不可靠,统计要以实际去重后的结果为准。
@@ -103,14 +104,16 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |
### 身份owner 维度API 字段名 `creator_ids`
### 身份维度
> **语义说明(重要)**`creator_ids`(含 `--mine` / `--creator-ids`)虽然 OpenAPI 字段名是 “creator”但服务端实际按 **owner文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**:我创建后转交他人的文档不会命中,他人创建后转给我(我成为 owner的会命中。用户说“我的 / 我创建的 / 我负责的”文档都路由到 `--mine`,但要清楚它返回的是“我 owner 的”
> **语义说明(重要)**`creator_ids`(含 `--mine` / `--creator-ids`)虽然字段名是 “creator”但服务端实际按 **owner文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**。真正的原始创建者使用 `original_creator_ids`CLI 为 `--created-by-me` / `--original-creator-ids`
| 参数 | 映射 | 说明 |
|---|---|---|
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键“我 owner 的”(**不是**“我最初创建的”);从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按 **owner** 匹配;**与 `--mine` 互斥** |
| `--created-by-me` | `original_creator_ids = [当前用户 open_id]` | bool。一键“我最初创建的”从当前登录用户身份解析 open_id取不到直接报错 |
| `--original-creator-ids ou_x,ou_y` | `original_creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按**原始创建者**匹配;**与 `--created-by-me` 互斥** |
### 时间维度(每个维度一对 since/until
@@ -187,7 +190,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
## 决策规则
- **身份快捷方式**:用户说“我的 / 我建的 / 我负责的”文档,直接 `--mine` 即可,不需要先查 contact 拿 open_id。注意 `--mine`**owner** 语义(我归属/负责的),不是“我最初创建的”——转交出去的不算、转交给我的算。
- **身份快捷方式**:用户说“我创建的 / 我建的 / 我最初创建的”文档, `--created-by-me`;用户说“我的 / 我负责的 / 我 owner 的”文档,用 `--mine``--mine` 是 owner 语义:转交出去的不算、转交给我的算。
- **时间维度选择**
- "我编辑的"、"我修改的" → `--edited-since` / `--edited-until`
- "我评论的"、"我回复过的" → `--commented-since` / `--commented-until`
@@ -197,10 +200,10 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- "某个文件夹下" → `--folder-tokens`doc-only
- "某个 wiki 空间下" → `--space-ids`wiki-only
- 两者不能同时使用,混用会报错
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。“我和张三的”(owner`--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传`--created-by-me``--original-creator-ids` 不要同时传。owner 维度与原始创建者维度可以组合,例如“我创建后转给王五 owner`--created-by-me --creator-ids ou_wangwu`
- **实体补全**
- 用户说"某个群里",先用 `lark-im``chat_id`
- 用户说“某人的 / 某人分享的”(非自己`--creator-ids` 按 owner 匹配),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- 用户说“某人负责/owner 的 / 某人创建的 / 某人分享的”(非自己),先用 `lark-contact` 查 open_id按语义`--creator-ids` / `--original-creator-ids` / `--sharer-ids`
- **查询语义下推**`--query` 支持的服务端高级语法(`intitle:``""``OR``-`)优先使用,不要先模糊搜再在客户端二次过滤。
- **query 填写边界**:只有标题片段、业务名词、项目名、会议名、文件内容关键词才应进入 `--query`。仅描述动作、时间范围、所有权、统计方式的词不算关键词,保持 `--query ""` 并依赖 filters。
- **证据核验**:列表/统计类答案必须来自搜索结果中的实际 URL/token 和类型/时间字段;内容问答必须能指出使用了哪些非污染候选。没有可验证候选时先扩大 query 或翻页,不要直接编总结。
@@ -222,7 +225,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
| code | 含义 | 处理 |
|---|---|---|
| `99992351` | `--creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
| `99992351` | `--creator-ids` / `--original-creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
## 时间范围自动裁剪(`--opened-*` 专有)

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