Compare commits

..

2 Commits

Author SHA1 Message Date
calendar-assistant
d539841ff6 fix(calendar): refine recurring event spec per review
- Remove redundant prerequisite link
- Document event_id structure ({event_uid}_{originalTime}) for
  identifying exceptions instead of relying on recurring_event_id
- Remove outdated notes (2s verification delay, COUNT unsupported)

Change-Id: Ib46ed4d5a20bc39b77e091f625278737895df423
2026-07-02 16:59:49 +08:00
calendar-assistant
49fe19e68d docs(calendar): add recurring event operation spec
Add references/lark-calendar-recurring.md with complete operation
flows for recurring events (edit all, delete all, edit this and
following, delete this and following, edit single instance).

Update SKILL.md routing table and core concepts to correctly
distinguish when to use the original recurring event_id vs instance
event_id, and introduce the Exception concept.

Change-Id: I84bca77bd060b1d74ff7f0befc7d71a10b63eb64
2026-07-02 16:38:57 +08:00
196 changed files with 76266 additions and 11287 deletions

View File

@@ -2,28 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
@@ -1355,7 +1333,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
WithMissingScopes("mail:user_mailbox.message:send").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
WithHint("run lark-cli auth login --scope calendar:event:create").
WithMissingScopes("calendar:event:create").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
buf, err := json.Marshal(e)
if err != nil {
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"identity": "user",
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
"missing_scopes": []any{"calendar:event:create"},
}
for k, want := range wantFields {

View File

@@ -10,14 +10,12 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
@@ -446,27 +444,28 @@ func extractMissingScopes(resp map[string]any) []string {
return out
}
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` query parameter so the console can pre-select them.
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
// QueryEscape both values — clientID and scopes both sit in the query
// string, and untrusted content must not be able to inject extra query
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
// open-platform base URL stays a single source of truth.
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
if len(scopes) == 0 {
return base
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
}
func intFromAny(v any) int {

View File

@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
}
}
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
}
}
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"scopes=scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"scopes=scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"clientID=good?q=injected"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"clientID=good#fragment"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
},
{
name: "slash in appID does not open a new path segment",
name: "slash in appID escapes path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
},
}
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
}
}
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") {

View File

@@ -7,7 +7,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -79,15 +78,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(file, match) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
@@ -134,9 +130,6 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -291,9 +284,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
@@ -323,109 +313,11 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"api_token",
"bot_token",
"refresh_token",
"secret_token",
"session_token",
"service_token",
"bearer_token",
"auth_token",
"authorization_token",
@@ -952,7 +844,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(file, raw string) bool {
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -961,8 +853,7 @@ func isPlaceholderCredentialURL(file, raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -976,46 +867,6 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {

View File

@@ -61,19 +61,6 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
}
}
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
if len(benign) != 0 {
@@ -645,45 +632,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
for _, item := range got {
@@ -700,7 +648,6 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -714,8 +661,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
}
}
@@ -777,68 +724,6 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
}
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
@@ -1167,12 +1052,10 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
}
}

View File

@@ -59,9 +59,13 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"%s/page/scope-apply?clientID=%s&scopes=%s",
core.ResolveOpenBaseURL(brand),
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)

View File

@@ -25,7 +25,7 @@ import (
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 15 * time.Second
fetchTimeout = 5 * time.Second
stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB

View File

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

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -651,7 +651,6 @@ func TestShortcuts(t *testing.T) {
want := []string{
"+chat-create",
"+chat-list",
"+chat-members-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",

View File

@@ -1,420 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
chatMembersListDefaultPageSize = 20
chatMembersListMaxPageSize = 100
// chatMembersListDefaultPageDelay throttles --page-all the same way the
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
// server-side member cap, where a large group drains many pages back to
// back and could otherwise trip rate limits.
chatMembersListDefaultPageDelay = 200
)
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
// returning users and bots in separate buckets (users[]/bots[]). It owns its
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
// the response is multi-bucket — the generic --page-all merger is built for
// single-array responses and would drop the bots[] bucket and the final-page
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
var ImChatMembersList = common.Shortcut{
Service: "im",
Command: "+chat-members-list",
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
Risk: "read",
// Declare the narrowest scope the API accepts so tokens carrying only
// im:chat.members:read are honored (same rationale as +chat-list).
Scopes: []string{"im:chat.members:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page.",
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
if chatID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
}
if !strings.HasPrefix(chatID, "oc_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
}
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
}
if n := runtime.Int("page-limit"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
}
if n := runtime.Int("page-delay"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
}
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
dry := common.NewDryRunAPI()
if chatMembersShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
return dry.
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
chatID := strings.TrimSpace(runtime.Str("chat-id"))
res, err := fetchChatMembers(ctx, runtime, chatID)
if err != nil {
return err
}
// The truncation signal is the whole reason this is a dedicated shortcut:
// surface it loudly so an agent never mistakes a capped list for a
// complete one.
if len(res.truncations) > 0 {
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
outData := map[string]interface{}{
"chat_id": chatID,
"users": res.users,
"bots": res.bots,
"truncations": res.truncations,
"has_more": res.hasMore,
"page_token": res.pageToken,
}
if res.userTotal != nil {
outData["user_total"] = res.userTotal
}
if res.botTotal != nil {
outData["bot_total"] = res.botTotal
}
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
renderChatMembersPretty(w, chatID, res)
})
return nil
},
}
// chatMembersResult is the aggregated view across one or more pages.
type chatMembersResult struct {
users []interface{}
bots []interface{}
truncations []interface{}
userTotal interface{}
botTotal interface{}
hasMore bool
pageToken string
}
// effectiveChatMembersPageSize resolves the page_size to request. When draining
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
// uses the maximum so a full walk takes the fewest round-trips. An explicit
// --page-size is always honored; without --page-all the smaller default is kept
// as a sensible single-page preview size.
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
return chatMembersListMaxPageSize
}
if n := runtime.Int("page-size"); n > 0 {
return n
}
return chatMembersListDefaultPageSize
}
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
// every page. An explicit --page-token disables the auto loop because the
// caller supplied a specific cursor (single-page fetch).
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// buildChatMembersParams builds the query params for one page request. The
// startToken (when non-empty) seeds the page_token; the loop overrides it per
// page. Returns the params and the normalized member-types CSV (already
// validated by Validate, so the error is only a defensive guard).
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
if err != nil {
return nil, err
}
params := map[string]interface{}{
"member_id_type": runtime.Str("member-id-type"),
"page_size": effectiveChatMembersPageSize(runtime),
}
if memberTypes != "" {
params["member_types"] = memberTypes
}
if startToken != "" {
params["page_token"] = startToken
}
return params, nil
}
// fetchChatMembers walks the list_members endpoint, honoring the four
// pagination flags the same way the generic --page-all path does. It merges
// each page into the aggregate as it arrives (rather than buffering every raw
// page), so peak memory is just the aggregated members plus the single most
// recent page — important for large groups under --page-limit 0.
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
auto := chatMembersShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
pageDelay := runtime.Int("page-delay")
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
if err != nil {
return nil, err
}
res := newChatMembersResult()
var lastData map[string]interface{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
for page := 0; ; page++ {
if pageToken != "" {
params["page_token"] = pageToken
}
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, err
}
addMemberBuckets(res, data)
lastData = data
hasMore, nextToken := common.PaginationMeta(data)
if !auto {
break
}
if !hasMore || nextToken == "" {
break
}
if nextToken == pageToken {
// Guard against a buggy server echoing the same cursor with
// has_more=true: without --page-limit we would loop forever.
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
break
}
if pageLimit > 0 && page+1 >= pageLimit {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
break
}
pageToken = nextToken
// Throttle between pages (only reached when another page follows), so
// draining a large untruncated list doesn't hammer the API.
if pageDelay > 0 {
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
}
}
if lastData != nil {
applyLastPageSignals(res, lastData)
}
return res, nil
}
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
// JSON output always carries arrays (never null).
func newChatMembersResult() *chatMembersResult {
return &chatMembersResult{
users: []interface{}{},
bots: []interface{}{},
truncations: []interface{}{},
}
}
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
// Concatenating every bucket is what avoids dropping bots[] — the bug the
// generic single-array --page-all merger would hit on this multi-bucket shape.
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
if u, ok := data["users"].([]interface{}); ok {
res.users = append(res.users, u...)
}
if b, ok := data["bots"].([]interface{}); ok {
res.bots = append(res.bots, b...)
}
}
// applyLastPageSignals copies the per-request signals from the FINAL page:
// has_more / page_token / truncations / totals. These must come from the last
// page, not page 1: truncations[] is emitted only on the final page (empty
// earlier), so reading it sooner would hide a server-side cap; user_total /
// bot_total are server-wide counts, and taking the final page's value keeps a
// single, consistent source rather than a possibly-stale earlier count.
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
res.hasMore, res.pageToken = common.PaginationMeta(data)
if t, ok := data["truncations"].([]interface{}); ok {
res.truncations = t
}
res.userTotal = data["user_total"]
res.botTotal = data["bot_total"]
}
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
// the same logic fetchChatMembers applies incrementally, kept as a pure
// function so the multi-bucket merge + last-page-signal semantics are unit
// tested in one place.
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
res := newChatMembersResult()
if len(pages) == 0 {
return res
}
for _, data := range pages {
addMemberBuckets(res, data)
}
applyLastPageSignals(res, pages[len(pages)-1])
return res
}
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
// the API's default of all types). Any element outside {user, bot} is rejected.
func normalizeMemberTypes(raw []string) (string, error) {
if len(raw) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p != "user" && p != "bot" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return strings.Join(out, ","), nil
}
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}
// writeChatMembersTruncationWarning emits a stderr warning for every
// server-side bucket cap reported in truncations[]. It uses the repo's plain
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
// pipes regardless of terminal encoding.
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
for _, t := range truncations {
tm, ok := t.(map[string]interface{})
if !ok {
continue
}
memberType := valueOrAll(tm["member_type"])
limit := tm["limit"]
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
}
}
func valueOrAll(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "member"
}
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
fmt.Fprintf(w, "Chat: %s\n", chatID)
// Show the server-wide total next to the fetched count: when truncated or
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
// what tells the reader how incomplete the list is.
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
for i, u := range res.users {
m, _ := u.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
for i, b := range res.bots {
m, _ := b.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
if len(res.truncations) > 0 {
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
}
if res.hasMore {
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
if res.pageToken != "" {
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
}
fmt.Fprintln(w)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// totalSuffix renders " of <total>" when the server-reported total exceeds the
// number actually fetched (so a truncated/partial bucket is obvious), and ""
// when the total is absent or already matches the fetched count.
func totalSuffix(total interface{}, fetched int) string {
n, ok := toInt(total)
if !ok || n <= fetched {
return ""
}
return fmt.Sprintf(" of %d", n)
}
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i), true
}
}
return 0, false
}

View File

@@ -1,325 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// page builds one list_members page payload shaped like the data object the
// server returns (users[]/bots[]/truncations[] plus paging + totals).
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
return map[string]interface{}{
"users": users,
"bots": bots,
"truncations": truncations,
"has_more": hasMore,
"page_token": pageToken,
"user_total": 324,
"bot_total": 2,
}
}
func us(ids ...string) []interface{} {
out := make([]interface{}, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]interface{}{"member_id": id})
}
return out
}
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
// (users AND bots) must be concatenated across pages, not just one of them.
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.users) != 3 {
t.Errorf("users: want 3 merged, got %d", len(res.users))
}
if len(res.bots) != 3 {
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
}
}
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
// is emitted only on the final page, so the merged view must take it from the
// last page rather than inherit page 1's empty slice.
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
pages := []map[string]interface{}{
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u2"), nil, limit, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.truncations) != 1 {
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
}
}
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
// signals come from the final page (so a --page-limit cutoff is visible).
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1"), nil, nil, true, "p2"),
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
}
res := mergeChatMemberPages(pages)
if !res.hasMore {
t.Error("has_more: want true from last page")
}
if res.pageToken != "p3" {
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
}
}
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
// are taken from the final page (not an earlier, possibly-different value).
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
}
res := mergeChatMemberPages(pages)
if n, _ := toInt(res.userTotal); n != 324 {
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
}
if n, _ := toInt(res.botTotal); n != 2 {
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
}
}
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
func TestChatMembersValidate(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
chatID string
wantErr bool
}{
{"valid oc_", "oc_abc", false},
{"empty", "", true},
{"missing oc_ prefix", "abc123", true},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
err := ImChatMembersList.Validate(context.Background(), rt)
if c.wantErr {
assertValidationError(t, c.name, err, "--chat-id")
continue
}
if err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
// assertValidationError checks err satisfies the repo's typed-error contract for
// a validation failure: a *errs.ValidationError carrying the expected Param, and
// problem metadata of category validation / subtype invalid_argument.
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
return
}
if ve.Param != wantParam {
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
}
func TestNormalizeMemberTypes(t *testing.T) {
cases := []struct {
in []string
want string
wantErr bool
}{
{nil, "", false},
{[]string{"user", "bot"}, "user,bot", false},
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
{[]string{"admin"}, "", true},
{[]string{""}, "", true},
}
for _, c := range cases {
got, err := normalizeMemberTypes(c.in)
if c.wantErr {
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
continue
}
if err != nil {
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
}
if got != c.want {
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
// drain with no explicit size → max; explicit size → honored; single page → default.
func TestEffectiveChatMembersPageSize(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
b map[string]bool
ints map[string]int
want int
}{
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
if got := effectiveChatMembersPageSize(rt); got != c.want {
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
}
}
}
// newChatMembersTestRuntime registers the shortcut's flags and returns a
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
t.Helper()
runtime := newUserShortcutRuntime(t, rt)
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("chat-id", "", "")
cmd.Flags().String("member-id-type", "open_id", "")
cmd.Flags().StringSlice("member-types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Int("page-delay", 200, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
for k, v := range str {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range b {
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range ints {
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
runtime.Cmd = cmd
return runtime
}
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
// fetch loop over mocked pages: users/bots merge across pages and the final
// page's truncations[] survives.
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
calls := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
}
calls++
token := req.URL.Query().Get("page_token")
if token == "" {
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
}), nil
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if calls != 2 {
t.Errorf("want 2 page calls, got %d", calls)
}
if len(res.users) != 3 {
t.Errorf("users: want 3, got %d", len(res.users))
}
if len(res.bots) != 2 {
t.Errorf("bots: want 2, got %d", len(res.bots))
}
if len(res.truncations) != 1 {
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
}
if res.hasMore {
t.Error("has_more: want false after draining all pages")
}
}
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
// leaves has_more=true so the caller knows the result is incomplete.
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
seq := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Every page reports more pages available, with an advancing token so the
// loop is stopped by --page-limit, not the non-advancing-token guard.
seq++
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if len(res.users) != 3 {
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
}
if !res.hasMore {
t.Error("has_more: want true (loop cut short by page-limit)")
}
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "reached page limit (3)") {
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
}
}

View File

@@ -10,7 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMembersList,
ImChatMessageList,
ImChatSearch,
ImChatUpdate,

View File

@@ -58,9 +58,45 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
return objectives, nil
}
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := BuildContentBlock(obj.Text, obj.Mention)
content := buildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
@@ -84,7 +120,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
// createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := BuildContentBlock(kr.Text, kr.Mention)
content := buildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{
"content": content,
}
@@ -188,7 +224,7 @@ var OKRBatchCreate = common.Shortcut{
for i, obj := range objectives {
// Objective creation
objContent := BuildContentBlock(obj.Text, obj.Mention)
objContent := buildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
@@ -205,7 +241,7 @@ var OKRBatchCreate = common.Shortcut{
// KR creations
for j, kr := range obj.KRs {
krContent := BuildContentBlock(kr.Text, kr.Mention)
krContent := buildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}

View File

@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}

View File

@@ -29,10 +29,15 @@ type RespCategory struct {
// RespCycle 周期
type RespCycle struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
TenantCycleID string `json:"tenant_cycle_id"`
Owner RespOwner `json:"owner"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
Score *float64 `json:"score,omitempty"`
}
// RespIndicator 指标
@@ -147,145 +152,3 @@ type RespProgress struct {
Content *string `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ========== Simple-style response types (semi-plain text format) ==========
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
type RespKeyResultSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
ObjectiveID string `json:"objective_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
}
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
type RespObjectiveSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
CycleID string `json:"cycle_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Notes *SemiPlainContent `json:"notes,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
}
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
type RespProgressSimple struct {
ID string `json:"progress_id"`
ModifyTime string `json:"modify_time"`
CreateTime *string `json:"create_time,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ToSimple converts KeyResult to RespKeyResultSimple.
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
if k == nil {
return nil
}
result := &RespKeyResultSimple{
ID: k.ID,
CreateTime: formatTimestamp(k.CreateTime),
UpdateTime: formatTimestamp(k.UpdateTime),
Owner: *k.Owner.ToResp(),
ObjectiveID: k.ObjectiveID,
Position: k.Position,
Score: k.Score,
Weight: k.Weight,
}
if k.Deadline != nil {
d := formatTimestamp(*k.Deadline)
result.Deadline = &d
}
result.Content = k.Content.ToSemiPlain()
return result
}
// ToSimple converts Objective to RespObjectiveSimple.
func (o *Objective) ToSimple() *RespObjectiveSimple {
if o == nil {
return nil
}
result := &RespObjectiveSimple{
ID: o.ID,
CreateTime: formatTimestamp(o.CreateTime),
UpdateTime: formatTimestamp(o.UpdateTime),
Owner: *o.Owner.ToResp(),
CycleID: o.CycleID,
Position: o.Position,
Score: o.Score,
Weight: o.Weight,
CategoryID: o.CategoryID,
}
if o.Deadline != nil {
d := formatTimestamp(*o.Deadline)
result.Deadline = &d
}
result.Content = o.Content.ToSemiPlain()
result.Notes = o.Notes.ToSemiPlain()
return result
}
// ToSimple converts ProgressV1 to RespProgressSimple.
func (p *ProgressV1) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.ModifyTime),
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.Percent,
}
if p.ProgressRate.Status != nil {
s := ProgressStatus(*p.ProgressRate.Status).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
if p.Content != nil {
resp.Content = p.Content.ToV2().ToSemiPlain()
}
return resp
}
// ToSimple converts Progress to RespProgressSimple.
func (p *Progress) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
createTime := formatTimestamp(p.CreateTime)
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.UpdateTime),
CreateTime: &createTime,
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.ProgressPercent,
}
if p.ProgressRate.ProgressStatus != nil {
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
resp.Content = p.Content.ToSemiPlain()
return resp
}

View File

@@ -26,7 +26,6 @@ var OKRCycleDetail = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
@@ -36,10 +35,6 @@ var OKRCycleDetail = common.Shortcut{
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -55,7 +50,6 @@ var OKRCycleDetail = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
style := runtime.Str("style")
// Paginate objectives under the cycle.
queryParams := map[string]interface{}{"page_size": "100"}
@@ -102,106 +96,85 @@ var OKRCycleDetail = common.Shortcut{
}
// For each objective, paginate key results and convert to response format.
if style == "simple" {
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
for i := range objectives {
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
krQuery := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
krPage := 0
for {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
if krPage > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
krPage++
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
if err != nil {
return err
}
respObj := obj.ToSimple()
if respObj == nil {
continue
}
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToSimple(); r != nil {
respKRs = append(respKRs, *r)
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var kr KeyResult
if err := json.Unmarshal(raw, &kr); err != nil {
continue
}
keyResults = append(keyResults, kr)
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
krQuery["page_token"] = pageToken
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
respObj := obj.ToResp()
if respObj == nil {
continue
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
contentText := ""
if o.Content != nil {
contentText = o.Content.Text
}
notesText := ""
if o.Notes != nil {
notesText = o.Notes.Text
}
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
krText := ""
if kr.Content != nil {
krText = kr.Content.Text
}
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
})
} else {
// richtext mode
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
if err != nil {
return err
}
respObj := obj.ToResp()
if respObj == nil {
continue
}
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
return nil
},
}

View File

@@ -46,38 +46,12 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
cycleStart := time.UnixMilli(startMs)
cycleEnd := time.UnixMilli(endMs)
// Two ranges overlap iff one starts before the other ends
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
}
// isCurrentActiveCycle checks whether a cycle is currently active:
// - current time is within the cycle's start and end time
// - cycle status is default (0) or normal (1)
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
nowUTC := now.UTC()
// Check time range: now must be >= start and <= end
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
return false
}
// Check status: must be default or normal
if cycle.CycleStatus == nil {
return false
}
status := *cycle.CycleStatus
return status == CycleStatusDefault || status == CycleStatusNormal
}
var OKRListCycles = common.Shortcut{
Service: "okr",
Command: "+cycle-list",
@@ -201,30 +175,14 @@ var OKRListCycles = common.Shortcut{
respCycles = append(respCycles, filtered[i].ToResp())
}
// Filter current active cycles
now := time.Now()
currentActiveCycles := make([]*RespCycle, 0)
for i := range filtered {
if isCurrentActiveCycle(&filtered[i], now) {
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
}
}
runtime.OutFormat(map[string]interface{}{
"cycles": respCycles,
"total": len(respCycles),
"current_active_cycles": currentActiveCycles,
"cycles": respCycles,
"total": len(respCycles),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
for _, c := range respCycles {
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
}
if len(currentActiveCycles) > 0 {
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
for _, c := range currentActiveCycles {
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
}
}
})
return nil
},

View File

@@ -5,10 +5,8 @@ package okr
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -262,156 +260,11 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
if len(cycles) != 0 {
t.Fatalf("cycles = %v, want empty", cycles)
}
// Assert current_active_cycles field exists and is a slice
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 0 {
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
}
}
// --- isCurrentActiveCycle unit tests ---
func TestIsCurrentActiveCycle(t *testing.T) {
t.Parallel()
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
cycle *Cycle
expected bool
}{
{
name: "active cycle with normal status",
cycle: &Cycle{
ID: "c1",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31 23:59:59
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "active cycle with default status",
cycle: &Cycle{
ID: "c2",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusDefault.Ptr(),
},
expected: true,
},
{
name: "cycle with invalid status",
cycle: &Cycle{
ID: "c3",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusInvalid.Ptr(),
},
expected: false,
},
{
name: "cycle with hidden status",
cycle: &Cycle{
ID: "c4",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusHidden.Ptr(),
},
expected: false,
},
{
name: "past cycle",
cycle: &Cycle{
ID: "c5",
StartTime: "1704067200000", // 2024-01-01
EndTime: "1719791999999", // 2024-06-30
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "future cycle",
cycle: &Cycle{
ID: "c6",
StartTime: "1830297600000", // 2028-01-01
EndTime: "1861833599999", // 2028-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "nil cycle status",
cycle: &Cycle{
ID: "c7",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: nil,
},
expected: false,
},
{
name: "invalid start time",
cycle: &Cycle{
ID: "c8",
StartTime: "invalid",
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "exact start time boundary",
cycle: &Cycle{
ID: "c9",
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "exact end time boundary",
cycle: &Cycle{
ID: "c10",
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCurrentActiveCycle(tt.cycle, now)
if result != tt.expected {
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
}
})
}
}
func TestCycleListExecute_WithCycles(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// Calculate timestamps relative to now to avoid test expiration
now := time.Now().UTC()
// Active cycle: 6 months before to 6 months after now
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
// Past cycle: 2 years before to 1.5 years before now
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
@@ -421,19 +274,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-active",
"start_time": strconv.FormatInt(activeStartMs, 10),
"end_time": strconv.FormatInt(activeEndMs, 10),
"cycle_status": 1, // normal
"id": "cycle-1",
"start_time": "1735689600000",
"end_time": "1751318400000",
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-1",
"score": 0.75,
},
map[string]interface{}{
"id": "cycle-past",
"start_time": strconv.FormatInt(pastStartMs, 10),
"end_time": strconv.FormatInt(pastEndMs, 10),
"cycle_status": 2, // invalid
"id": "cycle-2",
"start_time": "1704067200000",
"end_time": "1719792000000",
"cycle_status": 2,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-2",
"score": 0.5,
@@ -458,46 +311,6 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
if int(total) != 2 {
t.Fatalf("total = %v, want 2", total)
}
// Check current_active_cycles - should only contain cycle-active
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 1 {
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
}
activeCycle, ok := currentActive[0].(map[string]interface{})
if !ok {
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
}
if activeCycle["id"] != "cycle-active" {
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
}
// Verify removed fields are not present in the response
for _, c := range cycles {
cycleMap, _ := c.(map[string]interface{})
if _, ok := cycleMap["create_time"]; ok {
t.Fatal("create_time should not be present in response")
}
if _, ok := cycleMap["update_time"]; ok {
t.Fatal("update_time should not be present in response")
}
if _, ok := cycleMap["tenant_cycle_id"]; ok {
t.Fatal("tenant_cycle_id should not be present in response")
}
if _, ok := cycleMap["owner"]; ok {
t.Fatal("owner should not be present in response")
}
if _, ok := cycleMap["score"]; ok {
t.Fatal("score should not be present in response")
}
}
}
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {

View File

@@ -5,9 +5,7 @@ package okr
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"time"
)
@@ -263,9 +261,14 @@ func (c *Cycle) ToResp() *RespCycle {
return nil
}
resp := &RespCycle{
ID: c.ID,
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
ID: c.ID,
CreateTime: formatTimestamp(c.CreateTime),
UpdateTime: formatTimestamp(c.UpdateTime),
TenantCycleID: c.TenantCycleID,
Owner: *c.Owner.ToResp(),
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
Score: c.Score,
}
if c.CycleStatus != nil {
s := c.CycleStatus.ToString()
@@ -730,131 +733,6 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
}
}
// ========== SemiPlainContent (半纯文本格式) ==========
// Regex patterns for semi-plain text processing (pre-compiled for performance).
var (
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
multiSpaceRE = regexp.MustCompile(`\s+`)
)
// SemiPlainDoc represents a document link in semi-plain content.
type SemiPlainDoc struct {
Title string `json:"title"`
URL string `json:"url"`
}
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
// It contains plain text, mentions, docs, and images without rich formatting or position info.
type SemiPlainContent struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
Docs []SemiPlainDoc `json:"docs,omitempty"`
Images []string `json:"images,omitempty"`
}
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
if c == nil {
return nil
}
result := &SemiPlainContent{}
var textParts []string
for _, block := range c.Blocks {
if block.Paragraph != nil {
for _, elem := range block.Paragraph.Elements {
switch {
case elem.TextRun != nil && elem.TextRun.Text != nil:
textParts = append(textParts, *elem.TextRun.Text)
case elem.Mention != nil && elem.Mention.UserID != nil:
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
result.Mention = append(result.Mention, *elem.Mention.UserID)
case elem.DocsLink != nil:
doc := SemiPlainDoc{}
if elem.DocsLink.Title != nil {
doc.Title = *elem.DocsLink.Title
}
if elem.DocsLink.URL != nil {
doc.URL = *elem.DocsLink.URL
}
result.Docs = append(result.Docs, doc)
}
}
}
if block.Gallery != nil {
for _, img := range block.Gallery.Images {
if img.Src != nil {
result.Images = append(result.Images, *img.Src)
}
}
}
}
result.Text = strings.Join(textParts, "")
return result
}
// ToContentBlock converts SemiPlainContent to ContentBlock.
// Text and mentions are placed in a single paragraph (text first, then mentions).
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
if s == nil {
return nil
}
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
// Strip @{userID} placeholders from text to avoid duplicate mentions
// (these placeholders are only for readability in the output format)
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
// Collapse multiple spaces and trim
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
strippedText = strings.TrimSpace(strippedText)
// Add text element if stripped text is not empty
if strippedText != "" {
text := strippedText
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
})
}
// Add mention elements
for _, mention := range s.Mention {
m := mention
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &m,
},
})
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// BuildContentBlock converts text and mentions to a ContentBlock.
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
func BuildContentBlock(text string, mentions []string) *ContentBlock {
return (&SemiPlainContent{
Text: text,
Mention: mentions,
}).ToContentBlock()
}
// ProgressRateV1 进度率
type ProgressRateV1 struct {
Percent *float64 `json:"percent,omitempty"`

View File

@@ -57,9 +57,7 @@ func TestToRespMethods(t *testing.T) {
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
// Verify removed fields are not present in RespCycle
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
})
convey.Convey("Objective", func() {
@@ -520,449 +518,5 @@ func float64Ptr(v float64) *float64 { return &v }
// boolPtr returns a pointer to the given bool value.
func boolPtr(v bool) *bool { return &v }
// ========== SemiPlainContent Conversion Tests ==========
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello world"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Hello world" {
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
}
if len(sp.Mention) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
}
if len(sp.Docs) != 0 {
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
}
if len(sp.Images) != 0 {
t.Fatalf("expected 0 images, got %d", len(sp.Images))
}
}
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello "),
},
},
{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: strPtr("ou_123"),
},
},
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr(", how are you?"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
// Text includes @{userID} placeholder to preserve positional context
if sp.Text != "Hello @{ou_123} , how are you?" {
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
}
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
}
}
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Check out this doc: "),
},
},
{
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
DocsLink: &ContentDocsLink{
Title: strPtr("Design Doc"),
URL: strPtr("https://example.feishu.cn/docx/xxx"),
},
},
},
},
},
{
BlockElementType: BlockElementTypeGallery.Ptr(),
Gallery: &ContentGallery{
Images: []ContentImageItem{
{
Src: strPtr("https://example.com/img1.png"),
},
{
Src: strPtr("https://example.com/img2.png"),
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Check out this doc: " {
t.Fatalf("unexpected text: '%s'", sp.Text)
}
if len(sp.Docs) != 1 {
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
}
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
}
if len(sp.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(sp.Images))
}
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
t.Fatalf("unexpected images: %v", sp.Images)
}
}
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
t.Parallel()
var cb *ContentBlock
sp := cb.ToSemiPlain()
if sp != nil {
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
}
}
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Hello world",
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatal("expected paragraph block")
}
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
}
elem := block.Paragraph.Elements[0]
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected textRun element")
}
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
t.Fatalf("unexpected text: %v", elem.TextRun)
}
}
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Please review",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
t.Fatal("unexpected first element")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: " ",
Mention: []string{"ou_123"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Empty text should be skipped, only mention remains
if len(elems) != 1 {
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected mention element")
}
}
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Test",
Mention: []string{"ou_123"},
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
Images: []string{"https://img.png"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Docs and images are ignored in input conversion
if len(elems) != 2 {
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
}
}
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
t.Parallel()
// Simulate round-trip: output format has @{userID} in text,
// input conversion should strip them to avoid duplicate mentions
sp := &SemiPlainContent{
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
Mention: []string{"ou_zhangsan", "ou_lisi"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have 3 elements: 1 text (stripped) + 2 mentions
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
// Text should have placeholders stripped
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
// Note: space before comma is preserved from the placeholder's trailing space
expectedText := "任务一 ,任务二"
if *elems[0].TextRun.Text != expectedText {
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
}
// Mentions should be preserved as separate elements
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
t.Parallel()
// Text that is only placeholders should result in no text element
sp := &SemiPlainContent{
Text: " @{ou_123} @{ou_456} ",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have only 2 mention elements, no text element
if len(elems) != 2 {
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected first element to be mention")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected second element to be mention")
}
}
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
t.Parallel()
var sp *SemiPlainContent
cb := sp.ToContentBlock()
if cb != nil {
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
}
}
func TestBuildContentBlock_Conversion(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements, got %d", len(elems))
}
if *elems[0].TextRun.Text != "Test text" {
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
}
if *elems[1].Mention.UserID != "ou_123" {
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
}
if *elems[2].Mention.UserID != "ou_456" {
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
}
}
func TestToSimpleMethods(t *testing.T) {
t.Parallel()
// Test Objective.ToSimple()
text := "Objective text"
obj := &Objective{
ID: "obj-1",
Content: BuildContentBlock(text, []string{"ou_123"}),
Notes: BuildContentBlock("Note text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
CycleID: "cycle-1",
Score: float64Ptr(0.7),
Weight: float64Ptr(0.5),
Deadline: strPtr("1735776000000"),
}
simpleObj := obj.ToSimple()
if simpleObj == nil {
t.Fatal("expected non-nil RespObjectiveSimple")
}
if simpleObj.ID != "obj-1" {
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
}
// Text includes @{userID} placeholder for positional context
expectedContentText := "Objective text @{ou_123} "
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
}
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
}
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
t.Fatalf("unexpected score: %v", simpleObj.Score)
}
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
}
// Test KeyResult.ToSimple()
kr := &KeyResult{
ID: "kr-1",
ObjectiveID: "obj-1",
Content: BuildContentBlock("KR text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
Score: float64Ptr(0.5),
}
simpleKR := kr.ToSimple()
if simpleKR == nil {
t.Fatal("expected non-nil RespKeyResultSimple")
}
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
}
// Test ProgressV1.ToSimple()
progress := &ProgressV1{
ID: "prog-1",
ModifyTime: "1735776000000",
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
}
simpleProgress := progress.ToSimple()
if simpleProgress == nil {
t.Fatal("expected non-nil RespProgressSimple")
}
// Text includes @{userID} placeholder for positional context
expectedProgressText := "Progress text @{ou_mention} "
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
}
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
}
// Test Progress.ToSimple() (V2 progress record)
progressV2 := &Progress{
ID: "prog-v2-1",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
ProgressRate: &ProgressRate{
ProgressPercent: float64Ptr(80.0),
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
},
}
simpleProgressV2 := progressV2.ToSimple()
if simpleProgressV2 == nil {
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
}
if simpleProgressV2.ID != "prog-v2-1" {
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
}
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
t.Fatal("expected non-empty CreateTime for Progress V2")
}
expectedV2Text := "V2 progress text @{ou_v2_mention} "
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
}
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
}
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
}
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
}
}
// listTypePtr returns a pointer to the given ListType value.
func listTypePtr(v ListType) *ListType { return &v }

View File

@@ -1,311 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// patchParams holds the parsed parameters for the patch operation.
type patchParams struct {
Level string
TargetID string
Style string
Content *ContentBlock
Notes *ContentBlock
Score *float64
Deadline *string
UserIDType string
}
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
p := &patchParams{
Level: runtime.Str("level"),
TargetID: runtime.Str("target-id"),
Style: runtime.Str("style"),
UserIDType: runtime.Str("user-id-type"),
}
hasField := false
// Parse content if provided
if contentStr := runtime.Str("content"); contentStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
return nil, err
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
p.Content = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
p.Content = &cb
}
}
// Parse notes if provided (only for objective)
if notesStr := runtime.Str("notes"); notesStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
return nil, err
}
if p.Level != "objective" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
}
p.Notes = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
}
p.Notes = &cb
}
}
// Parse score if provided
if scoreStr := runtime.Str("score"); scoreStr != "" {
hasField = true
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
}
if score < 0 || score > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
}
// Check for exactly one decimal place
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
parts := strings.Split(scoreStrTrimmed, ".")
if len(parts) == 2 && len(parts[1]) > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
}
// Validation ensures at most one decimal place, so score is already correctly formatted
p.Score = &score
}
// Parse deadline if provided
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
hasField = true
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
}
if deadlineMs <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
}
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
// Anything less than 1e12 is likely seconds or a wrong unit
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
}
p.Deadline = &deadlineStr
}
// At least one field must be provided
if !hasField {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
}
return p, nil
}
// OKRPatch patches an objective or key result.
var OKRPatch = common.Shortcut{
Service: "okr",
Command: "+patch",
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
targetID := runtime.Str("target-id")
if targetID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
// Delegate content/notes/score/deadline validation to parsePatchParams
if _, err := parsePatchParams(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
p, err := parsePatchParams(runtime)
if err != nil {
return common.NewDryRunAPI().
PATCH("").
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
params := map[string]interface{}{
"user_id_type": p.UserIDType,
}
api := common.NewDryRunAPI()
if p.Level == "objective" {
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
Set("objective_id", p.TargetID)
} else {
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
Set("key_result_id", p.TargetID)
}
return api.Params(params).Body(body).
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
p, err := parsePatchParams(runtime)
if err != nil {
return err
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
queryParams := map[string]interface{}{
"user_id_type": p.UserIDType,
}
var path string
if p.Level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
}
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
}
result := map[string]interface{}{
"level": p.Level,
"target_id": p.TargetID,
"patched": map[string]bool{
"content": p.Content != nil,
"notes": p.Notes != nil,
"score": p.Score != nil,
"deadline": p.Deadline != nil,
},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
if p.Content != nil {
fmt.Fprintf(w, " - content: updated\n")
}
if p.Notes != nil {
fmt.Fprintf(w, " - notes: updated\n")
}
if p.Score != nil {
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
}
if p.Deadline != nil {
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
}
})
return nil
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import (
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
@@ -36,37 +35,12 @@ type createProgressRecordParams struct {
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content")
var contentV1 *ContentBlockV1
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
// Validate mention IDs are non-empty
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
// Build ContentBlock from semi-plain content (text + mentions)
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
targetType := runtime.Str("target-type")
targetTypeVal := targetTypeAllowed[targetType]
@@ -118,7 +92,7 @@ var OKRCreateProgressRecord = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
{Name: "progress-percent", Desc: "progress percentage"},
@@ -126,7 +100,6 @@ var OKRCreateProgressRecord = common.Shortcut{
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
content := runtime.Str("content")
@@ -136,36 +109,10 @@ var OKRCreateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
// If user provided docs or images in simple mode, warn that they are ignored
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
// Validate content is valid JSON and can be parsed as ContentBlock
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
targetID := runtime.Str("target-id")
@@ -266,43 +213,21 @@ var OKRCreateProgressRecord = common.Shortcut{
return err
}
style := runtime.Str("style")
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
return nil
},
}

View File

@@ -5,13 +5,11 @@ package okr
import (
"bytes"
"errors"
"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"
@@ -40,7 +38,6 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
}
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
// --- Validate tests ---
@@ -63,7 +60,6 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", "not-json",
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -81,7 +77,6 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-type", "objective",
})
if err == nil {
@@ -95,7 +90,6 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "abc",
"--target-type", "objective",
})
@@ -113,7 +107,6 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "invalid",
})
@@ -131,7 +124,6 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -146,7 +138,6 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--user-id-type", "invalid",
@@ -162,7 +153,6 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "999999999999",
@@ -181,7 +171,6 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "abc",
@@ -200,7 +189,6 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-status", "invalid_status",
@@ -231,7 +219,6 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -248,7 +235,6 @@ func TestProgressCreateDryRun(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
@@ -278,7 +264,6 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "75",
@@ -314,7 +299,6 @@ func TestProgressCreateExecute_Success(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "456",
"--target-type", "key_result",
})
@@ -346,7 +330,6 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "789",
"--target-type", "objective",
})
@@ -354,200 +337,3 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
t.Fatal("expected error for API failure")
}
}
// --- Simple mode tests ---
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "300",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "300" {
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
}
}
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "400",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
"--style", "simple",
"--target-id", "456",
"--target-type", "key_result",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "400" {
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
}
}
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"missing closing brace`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":" ","mention":[]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for empty text in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for docs in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
}

View File

@@ -26,7 +26,6 @@ var OKRGetProgressRecord = common.Shortcut{
Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
@@ -40,10 +39,6 @@ var OKRGetProgressRecord = common.Shortcut{
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -60,7 +55,6 @@ var OKRGetProgressRecord = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
userIDType := runtime.Str("user-id-type")
style := runtime.Str("style")
queryParams := map[string]interface{}{"user_id_type": userIDType}
@@ -75,45 +69,21 @@ var OKRGetProgressRecord = common.Shortcut{
return err
}
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
if len(resp.Content.Mention) > 0 {
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
}
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
return nil
},
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -26,35 +25,12 @@ type updateProgressRecordParams struct {
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content")
var contentV1 *ContentBlockV1
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
var progressRate *ProgressRateV1
if v := runtime.Str("progress-percent"); v != "" {
@@ -91,11 +67,10 @@ var OKRUpdateProgressRecord = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "progress-percent", Desc: "progress percentage"},
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
@@ -113,35 +88,9 @@ var OKRUpdateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
if v := runtime.Str("progress-percent"); v != "" {
@@ -209,43 +158,21 @@ var OKRUpdateProgressRecord = common.Shortcut{
return err
}
style := runtime.Str("style")
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
return nil
},
}

View File

@@ -5,13 +5,11 @@ package okr
import (
"bytes"
"errors"
"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"
@@ -47,7 +45,6 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for missing --progress-id")
@@ -61,7 +58,6 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
"+progress-update",
"--progress-id", "abc",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for invalid --progress-id")
@@ -90,7 +86,6 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", "not-json",
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for invalid --content JSON")
@@ -107,7 +102,6 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--user-id-type", "invalid",
})
if err == nil {
@@ -122,7 +116,6 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "-999999999999",
})
if err == nil {
@@ -140,7 +133,6 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-status", "invalid_status",
})
if err == nil {
@@ -170,7 +162,6 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -186,7 +177,6 @@ func TestProgressUpdateDryRun(t *testing.T) {
"+progress-update",
"--progress-id", "456",
"--content", validContentBlockJSON,
"--style", "richtext",
"--dry-run",
})
if err != nil {
@@ -211,7 +201,6 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
"+progress-update",
"--progress-id", "456",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "50",
"--progress-status", "overdue",
"--dry-run",
@@ -246,7 +235,6 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
"+progress-update",
"--progress-id", "789",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -277,202 +265,8 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
"+progress-update",
"--progress-id", "999",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for API failure")
}
}
// --- Simple mode tests ---
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/500",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "500",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "500",
"--content", validSemiPlainJSON,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "500" {
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
}
}
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/600",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "600",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions and progress rate
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "600",
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
"--style", "simple",
"--progress-percent", "80",
"--progress-status", "normal",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "600" {
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
}
}
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"invalid json`,
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
})
if err == nil {
t.Fatal("expected error for empty mention in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
})
if err == nil {
t.Fatal("expected error for images in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "700",
"--content", validSemiPlainJSON,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
}
}

View File

@@ -22,6 +22,5 @@ func Shortcuts() []common.Shortcut {
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
OKRPatch,
}
}

View File

@@ -5,7 +5,6 @@ package backward
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -18,30 +17,20 @@ import (
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// synthetic token prefixed with "fake_office_" and the backend requires
// "office_sheet_file" instead.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
// uploading an image whose parent_node is spreadsheetToken, mapping the
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
}
return sheetImageParentType
}
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
var v *errs.ValidationError
if errors.As(wrapped, &v) {
if v, ok := wrapped.(*errs.ValidationError); ok {
return "", nil, v.WithParam("--file")
}
return "", nil, wrapped

View File

@@ -332,21 +332,11 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}, nil
}
// maxBatchOperations caps how many sub-operations a single +batch-update may
// carry. Every translated op (with its own cells/properties payload) is held in
// the out slice at once before the whole batch is marshaled, so an unbounded
// operation count is the same unbounded-materialization hazard as the fan-out
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
}
if len(rawOps) > maxBatchOperations {
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)

View File

@@ -1,59 +1,4 @@
{
"+formula-verify": {
"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": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "sheet-name",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "range",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
},
{
"name": "max-locations",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max locations / samples per error type; default 20.",
"default": "20"
},
{
"name": "exit-on-error",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
}
]
},
"+workbook-info": {
"risk": "read",
"flags": [
@@ -80,32 +25,6 @@
}
]
},
"+revision-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": {
"risk": "write",
"flags": [
@@ -154,14 +73,6 @@
"desc": "Initial column count (default 20, max 200)",
"default": "20"
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet"
},
{
"name": "dry-run",
"kind": "system",
@@ -308,7 +219,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
"default": "-1"
},
{
@@ -604,7 +515,7 @@
"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 (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"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": [
"file",
"stdin"
@@ -1158,7 +1069,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
"desc": "Group nesting level to ungroup; default 1 (outermost)",
"default": "1"
},
{
@@ -1800,13 +1711,6 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2835,7 +2739,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [
"file",
"stdin"
@@ -2855,13 +2759,6 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2988,7 +2885,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3068,7 +2965,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3112,7 +3009,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [
"file",
"stdin"
@@ -3230,7 +3127,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"input": [
"file",
"stdin"
@@ -4169,7 +4066,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
},
{
"name": "dry-run",
@@ -4850,138 +4747,5 @@
"desc": ""
}
]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "end-version",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
},
{
"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 locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "history-version-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "History version to revert to (from +history-list)."
},
{
"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 locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "transaction-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Async revert transaction id (from +history-revert)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+changeset-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": "start-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
},
{
"name": "end-revision",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
"default": "-1"
}
]
}
}

View File

@@ -241,10 +241,6 @@
"description": "字体颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"font_family": {
"description": "字体名称/字族(例如 \"Arial\"、\"微软雅黑\"、\"宋体\"",
"type": "string"
},
"font_size": {
"description": "字体大小单位px/像素,例如 10、12、14",
"type": "number"
@@ -6502,9 +6498,6 @@
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",
@@ -6874,9 +6867,6 @@
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",

View File

@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -38,10 +38,9 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+changeset-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: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
{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: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
@@ -416,7 +405,7 @@ var flagDefs = map[string]commandDef{
{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: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -437,7 +426,7 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -474,7 +463,7 @@ var flagDefs = map[string]commandDef{
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
@@ -537,7 +526,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -643,45 +632,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+formula-verify": {
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: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
},
},
"+history-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
{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 locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
{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 locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
Risk: "write",
Flags: []flagDef{
@@ -784,14 +734,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+revision-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
@@ -826,7 +768,6 @@ var flagDefs = map[string]commandDef{
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); 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: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -881,7 +822,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: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -1000,7 +941,7 @@ var flagDefs = map[string]commandDef{
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 (dates / numbers land as text — use --sheets to preserve types), 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: "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`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. 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: "dry-run", Kind: "system", Type: "bool", Required: "optional"},

View File

@@ -6,6 +6,7 @@ package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"sync"
@@ -53,7 +54,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
flagSchemasOnce.Do(func() {
var idx flagSchemaIndex
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
return
}
if idx.Flags == nil {

View File

@@ -243,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
}
}
@@ -251,20 +251,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// already reported above). Apply to both `number` and `integer` types.
if num, ok := value.(float64); ok {
if schema.Minimum != nil && num < *schema.Minimum {
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
}
if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
}
}
// Array length bounds — only checked when value is an array.
if arr, ok := value.([]interface{}); ok {
if schema.MinItems != nil && len(arr) < *schema.MinItems {
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
}
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
}
}
@@ -282,7 +282,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%s", msg)
}
}
@@ -295,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
}
if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
}
}
@@ -305,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required {
if _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
}
}
if schema.Properties != nil {
@@ -357,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
sort.Strings(extras)
for _, key := range extras {
if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
}
if schema.AdditionalProperties.Schema != nil {
child := key

View File

@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
// parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64)
if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
}
case "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
}
}
}

View File

@@ -10,7 +10,6 @@ package sheets
import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -45,8 +44,7 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
// classification and only adds the domain's flag param.
func sheetsInputStatError(flag string, err error) error {
wrapped := common.WrapInputStatErrorTyped(err)
var v *errs.ValidationError
if errors.As(wrapped, &v) {
if v, ok := wrapped.(*errs.ValidationError); ok {
return v.WithParam(sheetsFlagParam(flag))
}
return wrapped
@@ -54,30 +52,21 @@ func sheetsInputStatError(flag string, err error) error {
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// synthetic token prefixed with "fake_office_" and the backend requires
// "office_sheet_file" instead.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
}
return sheetImageParentType
}
@@ -451,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
@@ -462,9 +451,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}

View File

@@ -215,8 +215,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if borderStyles != nil {
prototype["border_styles"] = borderStyles
}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -226,13 +225,6 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -307,7 +299,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
return nil, err
}
clearType := normalizeClearType(runtime.Str("scope"))
ops := make([]interface{}, 0, len(ranges))
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -390,10 +382,13 @@ var DropdownDelete = common.Shortcut{
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
// validateDropdownRanges enforces the shared maxBatchRanges cap.
if _, err := validateDropdownRanges(runtime); err != nil {
ranges, err := validateDropdownRanges(runtime)
if err != nil {
return err
}
if len(ranges) > 100 {
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -437,8 +432,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
}
prototype = map[string]interface{}{"data_validation": validation}
}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -448,13 +442,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -474,25 +461,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
// ─── helpers resurrected from B3 (used here + future skills) ──────────
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
// the number of ops materialized into one batch_update.
const maxBatchRanges = 100
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
// summing past it. totalCells is int64 to stay overflow-safe.
func checkBatchStampBudget(totalCells int64) error {
if totalCells > maxStampMatrixCells {
return sheetsValidationForFlag("ranges",
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
totalCells, maxStampMatrixCells)
}
return nil
}
// validateDropdownRanges parses --ranges, requires every entry to carry a
// sheet prefix, and returns the parsed list.
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
@@ -522,9 +490,6 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
}
out = append(out, s)
}
if len(out) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
}
return out, nil
}

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_changeset ─────────────────────────────────────────────
//
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
// human or reviewing agent can verify whether an AI edit actually fulfilled
// the user's request.
//
// - --start-revision is the "before" baseline (required, >= 1).
// - --end-revision is optional; when omitted it defaults to the latest
// revision, returning every changeset from start up to now.
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
const changesetMaxRevGap = 20
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
var ChangesetGet = common.Shortcut{
Service: "sheets",
Command: "+changeset-get",
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+changeset-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := changesetRevisions(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := changesetInput(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := changesetInput(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
},
}
// changesetRevisions reads and validates the start / end revision flags.
// end <= 0 means "not provided" (default to latest, resolved server-side); a
// provided end must be >= start and within the 20-revision gap.
func changesetRevisions(runtime flagView) (start int, end int, err error) {
start = runtime.Int("start-revision")
end = runtime.Int("end-revision")
if start < 1 {
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
}
if end > 0 {
if end < start {
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
}
if end-start+1 > changesetMaxRevGap {
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
}
}
return start, end, nil
}
// changesetInput builds the get_changeset tool input. end_revision is only
// sent when explicitly provided; otherwise the server defaults to latest.
func changesetInput(runtime flagView) (map[string]interface{}, error) {
start, end, err := changesetRevisions(runtime)
if err != nil {
return nil, err
}
input := map[string]interface{}{
"start_revision": start,
}
if end > 0 {
input["end_revision"] = end
}
return input, nil
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
// is only sent when explicitly provided, otherwise the server defaults to the
// latest revision.
func TestChangesetGet_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "start + end bounded range",
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
wantInput: map[string]interface{}{
"start_revision": float64(120),
"end_revision": float64(135),
},
},
{
name: "start only → end omitted (server defaults to latest)",
args: []string{"--url", testURL, "--start-revision", "120"},
wantInput: map[string]interface{}{
"start_revision": float64(120),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChangesetGet, tt.args)
got := decodeToolInput(t, body, "get_changeset")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestChangesetGet_Validation covers the client-side revision guards, which
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
func TestChangesetGet_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantSub string
}{
{
name: "start-revision must be >= 1",
args: []string{"--url", testURL, "--start-revision", "0"},
wantSub: "start-revision must be >= 1",
},
{
name: "end before start rejected",
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
wantSub: "end-revision",
},
{
name: "gap over 20 rejected",
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
wantSub: "version gap exceeds limit",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
}
})
}
}

View File

@@ -1,167 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_formula_verify ───────────────────────────────────────
//
// Wraps verify_formula (read): scan formulas + cell error states across one
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
// into a recalc.py-shaped JSON status report. The contract is the single
// AI self-check entry point for the R10 "write → verify zero-error"
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
// every visible sub-sheet's current_region.
var FormulaVerify = common.Shortcut{
Service: "sheets",
Command: "+formula-verify",
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+formula-verify"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
return err
}
return validateFormulaVerifyLimits(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
if err != nil {
return err
}
runtime.Out(out, nil)
if runtime.Bool("exit-on-error") {
return formulaVerifyExitOnError(out)
}
return nil
},
}
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
// non-empty (passing both is the high-frequency reflex confusion when the
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
// documented "scan every visible sub-sheet" path. Control-char checks reuse
// requireSheetSelector's logic on each item.
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
if len(ids) > 0 && len(names) > 0 {
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
}
for _, id := range ids {
if err := requireSheetSelector(id, ""); err != nil {
return err
}
}
for _, name := range names {
if err := requireSheetSelector("", name); err != nil {
return err
}
}
return nil
}
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
// negative flag value can't silently degrade the scan (the server-side
// default would otherwise mask the typo).
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
}
return nil
}
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
// `--sheet-id ""` doesn't masquerade as a real entry.
func nonEmptySliceItems(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if trimmed := strings.TrimSpace(v); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
// excel_id is required; everything else is optional per the schema.
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
}
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
input["sheet_ids"] = ids
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
// The verify_formula schema only declares sheet_ids; the facade
// accepts sheet_names as a parallel optional field so name-based
// selection works without forcing the caller to pre-resolve. Mirrors
// how the other read shortcuts pack both fields via
// sheetSelectorForToolInput.
input["sheet_names"] = names
}
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
input["ranges"] = ranges
}
if runtime.Changed("max-locations") {
input["max_locations_per_error"] = runtime.Int("max-locations")
}
return input
}
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
// CLI exit when the caller passed --exit-on-error. status="errors_found"
// is the only failure mode for this flag: "partial" means truncated but the
// scanned slice is clean, and "success" is obviously clean. A missing /
// unknown status is treated as a typed internal error because the tool's
// schema guarantees the field and we don't want a silent zero-exit.
func formulaVerifyExitOnError(out interface{}) error {
m, ok := out.(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: missing status field in tool output")
}
status, _ := m["status"].(string)
switch status {
case "success", "partial":
return nil
case "errors_found":
total, _ := util.ToFloat64(m["total_errors"])
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: unexpected status %q", status)
}
}

View File

@@ -1,213 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
// common input combinations: no selector (workbook-wide scan), explicit
// sheet_ids, explicit ranges, and the optional max_locations_per_error
// field. The test exercises the One-OpenAPI body
// directly so the schema field names stay locked to the canonical
// tool-schemas.json verify_formula node.
func TestFormulaVerify_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "no selector — workbook-wide scan defaults",
args: []string{"--url", testURL},
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "sheet_ids multi via repeat",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_ids": []interface{}{testSheetID, testSheetID2},
},
},
{
name: "sheet_names multi via comma",
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
},
},
{
name: "ranges + max_locations",
args: []string{
"--url", testURL,
"--range", "A1:Z200",
"--range", "AA1:AZ100",
"--max-locations", "5",
},
wantInput: map[string]interface{}{
"excel_id": testToken,
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
"max_locations_per_error": float64(5),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, FormulaVerify, tt.args)
got := decodeToolInput(t, body, "verify_formula")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
// surface as a 403 from the gateway.
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
if len(calls) == 0 {
t.Fatalf("dry-run produced no api calls")
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
if !strings.HasSuffix(url, "/tools/invoke_read") {
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
}
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
t.Errorf("url = %q, want %q", url, want)
}
}
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
// rule on the two multi-value flags. Both empty is the documented
// workbook-wide scan path, so we only reject the both-supplied case.
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--sheet-name", "Sheet1",
"--dry-run",
})
ve := requireValidation(t, err, "mutually exclusive")
gotParams := map[string]bool{}
for _, p := range ve.Params {
gotParams[p.Name] = true
}
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
}
}
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
// `--max-locations 0`, which would otherwise be silently swallowed by the
// "explicit value but unset" comparison in the input builder.
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "max-locations=0",
args: []string{"--url", testURL, "--max-locations", "0"},
want: "--max-locations must be > 0",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
requireValidation(t, err, c.want)
})
}
}
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
// contract: success/partial → no error; errors_found → typed validation
// error with SubtypeFailedPrecondition; missing or unknown status →
// typed internal error so a silent zero-exit can never happen.
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
t.Parallel()
t.Run("success returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
t.Fatalf("success path returned err: %v", err)
}
})
t.Run("partial returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
t.Fatalf("partial path returned err: %v", err)
}
})
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{
"status": "errors_found",
"total_errors": float64(7),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Message, "7 formula error") {
t.Errorf("message %q must surface the error count", ve.Message)
}
if ve.Hint == "" {
t.Errorf("hint must be set so AI agents know to re-run after fixes")
}
})
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError("oops")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
}

View File

@@ -1,97 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
//
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
// invoke_read endpoint. The tool returns a sheet's version history. The
// facade-agg tool already performs the response transform (minor_histories
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
// so the CLI passes the tool output straight through and does NOT re-implement
// the transform client-side.
//
// History is workbook-level (no sheet selector), mirroring +workbook-info:
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
//
// Flags are declared inline here rather than via flagsFor(): the generated
// flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec
// (BE-3) and must not be hand-edited, so this hand-written shortcut owns its
// own flag set. The two locator flags match +workbook-info's shape exactly.
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
func historyLocatorFlags() []common.Flag {
return []common.Flag{
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
}
}
// HistoryList wraps the history_list tool: list a spreadsheet's history
// versions. Each item carries history_version_id / create_time / action /
// all_block_revision (projected server-side). An empty sheet yields an empty
// list and exit 0.
//
// Backward pagination: --end-version (optional int) maps to the tool's
// `end_version` parameter. Omit on the first call to fetch the latest page.
// On subsequent pages pass the previous response's next_end_version as
// --end-version. The tool returns next_end_version + has_more only when
// more history exists; both fields are absent at the earliest page.
var HistoryList = common.Shortcut{
Service: "sheets",
Command: "+history-list",
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: append(historyLocatorFlags(),
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
),
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, "history_list", historyListInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
if err != nil {
return err
}
// Pass the tool output through verbatim — facade-agg already shaped it.
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Capture a history_version_id from the result to feed +history-revert.",
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
},
}
// historyListInput composes the history_list tool input. --end-version is
// optional: include it only when explicitly set so the server treats absence
// as "first page (latest)".
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
in := map[string]interface{}{"excel_id": token}
if runtime.Changed("end-version") {
in["end_version"] = runtime.Int("end-version")
}
return in
}

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 (BE-2: +history-revert / +history-revert-status) ──
//
// Two thin callTool wrappers over the facade-agg history tools:
// - +history-revert → history_revert (write) — async revert
// - +history-revert-status → history_revert_status (read) — poll outcome
//
// Both target a single history version via --history-version-id (the id
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
// transaction id that +history-revert-status then polls, distinguishing
// in-progress / success / failure from the tool output (passed through
// verbatim — no client-side shaping).
//
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
// tools are registered but their downstream RPC wiring is a DEFERRED
// follow-up; today they return a "not wired yet" guard error from the gateway,
// which surfaces here as a normal tool error. These CLI shortcuts are correct
// thin wrappers and will work end-to-end once the backend follow-up lands —
// this is NOT a CLI blocker. See self_check.md.
//
// Flags are declared inline (historyLocatorFlags + history-version-id) rather
// than via flagsFor(), because flag_defs_gen.go / data/flag-defs.json are
// synced from sheet-skill-spec (BE-3) and must not be hand-edited.
// historyVersionIDFlag is the target-version selector shared by +history-revert.
// Required at the cli surface (cobra MarkFlagRequired): a missing value yields
// cobra's standard "required flag(s) \"history-version-id\" not set" message
// before Validate runs. We still trim + reject control-chars in Validate to
// reject empty strings ("--history-version-id "" "), which cobra accepts.
func historyVersionIDFlag() common.Flag {
return common.Flag{
Name: "history-version-id",
Type: "string",
Required: true,
Desc: "History version to act on (from +history-list).",
}
}
func historyRevertFlags() []common.Flag {
return append(historyLocatorFlags(), historyVersionIDFlag())
}
// validateHistoryVersionID enforces the required, control-char-clean
// --history-version-id. Returns the trimmed value so callers reuse it.
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("history-version-id"))
if id == "" {
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
}
return id, nil
}
func historyRevertInput(token, versionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"history_version_id": versionID,
}
}
// transactionIDFlag is the async-revert receipt selector used by
// +history-revert-status: the transaction_id returned by +history-revert (NOT a
// history version id — the facade-agg status tool keys on transaction_id).
// Required at the cli surface (cobra MarkFlagRequired) — same gating model as
// historyVersionIDFlag. Validate still trims + rejects empty/control-char
// values to catch the case where cobra accepts --transaction-id with an
// empty-string value.
func transactionIDFlag() common.Flag {
return common.Flag{
Name: "transaction-id",
Type: "string",
Required: true,
Desc: "Async revert transaction id (from +history-revert).",
}
}
func historyRevertStatusFlags() []common.Flag {
return append(historyLocatorFlags(), transactionIDFlag())
}
// validateTransactionID enforces the required, trimmed --transaction-id and
// returns it for reuse.
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("transaction-id"))
if id == "" {
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
}
return id, nil
}
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"transaction_id": transactionID,
}
}
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
// spreadsheet to the given history version. --history-version-id is required
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
// Validate runs with cobra's standard "required flag(s)" error (which the
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
// trim + reject empty / control-char values in Validate to catch the
// case where cobra accepts --history-version-id with an empty-string value.
var HistoryRevert = common.Shortcut{
Service: "sheets",
Command: "+history-revert",
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateHistoryVersionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
versionID, err := validateHistoryVersionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
},
}
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
// outcome of a prior +history-revert. The tool output distinguishes
// in-progress / success / failure and is passed through verbatim.
var HistoryRevertStatus = common.Shortcut{
Service: "sheets",
Command: "+history-revert-status",
Description: "Poll the status of a history revert (in-progress / success / failure).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertStatusFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateTransactionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
txnID, err := validateTransactionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -1,167 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
// facade-agg tool, routes through the correct read/write invoke endpoint, and
// builds the expected tool input (excel_id always; history_version_id for the
// revert pair).
func TestHistoryShortcuts_DryRun(t *testing.T) {
t.Parallel()
const versionID = "histVER123"
const txnID = "txn-abc-123"
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantPath string // invoke_read | invoke_write suffix
wantInput map[string]interface{}
}{
{
name: "+history-list via --url",
sc: HistoryList,
args: []string{"--url", testURL},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list via --spreadsheet-token",
sc: HistoryList,
args: []string{"--spreadsheet-token", testToken},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list paginates with --end-version",
sc: HistoryList,
args: []string{"--url", testURL, "--end-version", "12345"},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"end_version": float64(12345), // post-JSON-unmarshal numeric type
},
},
{
name: "+history-revert routes to invoke_write with version id",
sc: HistoryRevert,
args: []string{"--url", testURL, "--history-version-id", versionID},
toolName: "history_revert",
wantPath: "invoke_write",
wantInput: map[string]interface{}{
"excel_id": testToken,
"history_version_id": versionID,
},
},
{
name: "+history-revert-status routes to invoke_read with transaction id",
sc: HistoryRevertStatus,
args: []string{"--url", testURL, "--transaction-id", txnID},
toolName: "history_revert_status",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"transaction_id": txnID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
if !containsSuffix(callURL, tt.wantPath) {
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
}
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
// missing required selector before any request is sent, with two distinct
// gates by design:
//
// - +history-revert: --history-version-id is cobra-required (Required=true
// in the flag def → MarkFlagRequired). cobra refuses the call before
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
// The test rig invokes the shortcut via cmd.Execute and observes the raw
// cobra error directly (no dispatcher wrap), so we assert the cobra text
// contract instead of the typed envelope.
//
// - +history-revert-status: --transaction-id is cobra-optional;
// requiredness is enforced inside Validate so we still get a typed,
// flag-tagged *errs.ValidationError with Param="--transaction-id".
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
t.Parallel()
t.Run(HistoryRevert.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
}
})
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
}
})
}
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
// api call's url, so tests can assert read vs. write endpoint routing.
func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRun["api"].([]interface{})
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
return url
}
func containsSuffix(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_revision_get ───────────────────────────────────────────
//
// RevisionGet is a read-only derivative over get_workbook_structure that
// projects out only the document revision (version number). The backend
// surfaces `revision` on every read/write tool response, so this shortcut
// needs no dedicated backend tool — it issues the lightest existing read
// (no range, just the workbook token) and narrows the payload to the single
// field callers want.
//
// The revision is the anchor for recover / undo. Callers that have just run a
// write already have it in that write's response; +revision-get is the
// explicit, zero-side-effect way to fetch the current value on its own.
var RevisionGet = common.Shortcut{
Service: "sheets",
Command: "+revision-get",
Description: "Get the spreadsheet's current document revision (version number).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+revision-get"),
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, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
},
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, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
if err != nil {
return err
}
rev, err := projectRevision(out)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"revision": rev}, nil)
return nil
},
Tips: []string{
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
},
}
// projectRevision narrows a get_workbook_structure response to its `revision`
// field. An absent revision means the backend predates revision injection on
// read responses; surface that as an explicit error rather than emitting a
// silent null.
func projectRevision(out interface{}) (interface{}, error) {
obj, ok := out.(map[string]interface{})
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure returned non-object output")
}
rev, ok := obj["revision"]
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure did not return a revision (backend may not support it yet)")
}
return rev, nil
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestRevisionGetProjectRevision(t *testing.T) {
t.Parallel()
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
out := map[string]interface{}{
"revision": float64(60),
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
}
got, err := projectRevision(out)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != float64(60) {
t.Errorf("revision = %v, want 60", got)
}
})
t.Run("errors when revision is absent", func(t *testing.T) {
out := map[string]interface{}{"sheets": []interface{}{}}
if _, err := projectRevision(out); err == nil {
t.Error("expected an error when revision is missing, got nil")
}
})
t.Run("errors on a non-object output", func(t *testing.T) {
if _, err := projectRevision("not-an-object"); err == nil {
t.Error("expected an error for non-object output, got nil")
}
})
}

View File

@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("range is empty")
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
}
dim1, idx1, err := parseA1Position(parts[0])
if err != nil {
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
return "", 0, 0, err
}
if dim1 != dim2 {
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, 0, fmt.Errorf("end position is before start")
}
return dim1, idx1, idx2, nil
}
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
func parseA1Position(s string) (dimension string, idx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, fmt.Errorf("position is empty")
}
isDigits := true
isLetters := true
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
if isDigits {
n, _ := strconv.Atoi(s)
if n <= 0 {
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
}
return "row", n - 1, nil
}
if isLetters {
return "column", letterToColumnIndex(s), nil
}
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
}
// columnIndexToLetter converts a 0-based column index to the spreadsheet

View File

@@ -382,32 +382,6 @@ func (p *tablePayload) validate() error {
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
}
}
return p.checkCellBudget()
}
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
// these cells come from the caller's own --sheets/--values payload rather than a
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
// process before the first write leaves.
const maxTablePutCells = 1_000_000
// checkCellBudget rejects a payload whose total materialized cell count across
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
// pathological row/column counts.
func (p *tablePayload) checkCellBudget() error {
var total int64
for i := range p.Sheets {
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
}
if total > maxTablePutCells {
return common.ValidationErrorf(
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
total, maxTablePutCells)
}
return nil
}

View File

@@ -123,26 +123,6 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.ValidationErrorf("--title is required")
}
// --type bitable 建一张空白多维表格子表operation=create_bitable默认 sheet 为普通
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令row-count/col-count 不适用。
sheetType := strings.TrimSpace(runtime.Str("type"))
if sheetType == "" {
sheetType = "sheet"
}
if sheetType != "sheet" && sheetType != "bitable" {
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
}
if sheetType == "bitable" {
input := map[string]interface{}{
"excel_id": token,
"operation": "create_bitable",
"sheet_name": strings.TrimSpace(runtime.Str("title")),
}
if runtime.Changed("index") {
input["target_index"] = runtime.Int("index")
}
return input, nil
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
@@ -856,19 +836,13 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
}
noHeader := false
payload := &tablePayload{Sheets: []tableSheetSpec{{
return &tablePayload{Sheets: []tableSheetSpec{{
Name: valuesSheetName,
Mode: "overwrite",
Header: &noHeader,
Columns: cols,
Rows: rows,
}}}
// --values bypasses tablePayload.validate(), so enforce the cell budget here
// too — otherwise a giant --values array materializes unbounded.
if err := payload.checkCellBudget(); err != nil {
return nil, err
}
return payload, nil
}}}, nil
}
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
@@ -1272,7 +1246,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
case "font_color", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap":
return true

View File

@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
// CellsSetStyle stamps a single style block across every cell in --range.
// Style is composed from a dozen flat flags (background-color, font-color,
// font-family, font-size, font-style, font-weight, font-line,
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
// --border-styles for the only field that still needs a nested object. At
// least one flag must be set.
// font-size, font-style, font-weight, font-line, horizontal-alignment,
// vertical-alignment, word-wrap, number-format) plus --border-styles for
// the only field that still needs a nested object. At least one flag must
// be set.
var CellsSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-set-style",
@@ -165,9 +165,6 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
}
@@ -453,9 +450,6 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
@@ -631,23 +625,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
}
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("empty range")
}
parts := strings.SplitN(rangeStr, ":", 2)
if len(parts) == 1 {
// single cell, e.g. "A1"
if _, _, ok := splitCellRef(parts[0]); !ok {
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
}
return 1, 1, nil
}
startCol, startRow, ok1 := splitCellRef(parts[0])
endCol, endRow, ok2 := splitCellRef(parts[1])
if !ok1 || !ok2 {
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
}
if endRow < startRow || endCol < startCol {
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
}
return endRow - startRow + 1, endCol - startCol + 1, nil
}
@@ -698,30 +692,9 @@ func letterToColumnIndex(letters string) int {
return n - 1
}
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
// will materialize from a single A1 range. The backing tools take an explicit
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
// doubled again by json.Marshal) that OOMs the process before the request even
// leaves. 200000 matches the documented --max-cells safety cap.
const maxStampMatrixCells = 200000
// checkStampMatrixBudget rejects a range whose materialized cell count would
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
// computed in int64 to stay safe against overflow on pathological ranges.
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
return sheetsValidationForFlag(flagName,
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
rangeStr, total, maxStampMatrixCells)
}
return nil
}
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
// single attribute (style / data_validation) across an entire range.
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
cells := make([][]interface{}, rows)
for r := range cells {

View File

@@ -25,8 +25,8 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
// synthetic token and must upload with "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -36,12 +36,9 @@ func TestSheetMediaParentType(t *testing.T) {
}{
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
{"empty token", "", sheetImageParentType},
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
{"fake_office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficeTokenPrefix, officeSheetFileParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -65,8 +62,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
wantParentType string
}{
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -1,272 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"io"
"runtime"
"strings"
"testing"
)
// These benchmarks back the memory review of the sheets fan-out / download
// paths. They measure two hot spots:
//
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
// explodes into millions of heap maps with no upper bound.
//
// 2. the export-download reader — strings.NewReader(string(rawBody)) copies
// the whole downloaded file once more before saving it.
//
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|DownloadReader' -benchmem
var styleProto = map[string]interface{}{
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
}
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := fillCellsMatrix(rows, cols, styleProto)
if len(m) != rows {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
// materializing a large fan-out matrix, so the review doc can quote real MB.
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
func TestFanoutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
cases := []struct {
name string
rows, cols int
}{
{"A1:Z10000 (260K cells)", 10000, 26},
{"A1:Z100000 (2.6M cells)", 100000, 26},
}
for _, c := range cases {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m := fillCellsMatrix(c.rows, c.cols, styleProto)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
//
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
c := make([]tableColumnSpec, cols)
r := make([][]interface{}, rows)
for i := range r {
row := make([]interface{}, cols)
for j := range row {
row[j] = "x"
}
r[i] = row
}
return &tableSheetSpec{Columns: c, Rows: r}
}
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
spec := makeTypelessSpec(rows, cols)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m, err := buildSheetMatrix(spec, true)
if err != nil || len(m) != rows+1 {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
// a large table-put matrix (the cost checkCellBudget now prevents), so the
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
func TestTablePutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
for _, c := range []struct {
name string
rows, cols int
}{
{"100000×26 (2.6M cells)", 100000, 26},
} {
spec := makeTypelessSpec(c.rows, c.cols)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m, _ := buildSheetMatrix(spec, true)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- export-download reader copy ---
func benchDownloadReader(b *testing.B, size int, useStringCopy bool) {
raw := bytes.Repeat([]byte("x"), size)
sink := make([]byte, 32*1024)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var r io.Reader
if useStringCopy {
r = strings.NewReader(string(raw)) // current code: extra full-size copy
} else {
r = bytes.NewReader(raw) // fix: no copy
}
for {
if _, err := r.Read(sink); err != nil {
break
}
}
}
}
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
func TestStampMatrixBudgetCap(t *testing.T) {
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
t.Fatalf("199992 cells should pass, got: %v", err)
}
// Exactly at the cap → allowed.
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
}
// Just over the cap → rejected.
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
t.Fatal("200001 cells should be rejected")
}
// The pathological case from the review (2.6M cells) → rejected.
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
t.Fatal("2.6M-cell fan-out should be rejected")
}
}
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
// batch-update operation count (follow-up to the single fan-out cap) ---
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
// bounded before that allocation, summed across all sheets.
func TestTablePutCellBudgetCap(t *testing.T) {
// 1000×1000 = 1,000,000 == cap → allowed.
atCap := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1000),
}}}
if err := atCap.checkCellBudget(); err != nil {
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
}
// 1000×1001 = 1,001,000 > cap → rejected.
over := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1001),
}}}
if err := over.checkCellBudget(); err == nil {
t.Fatal("1,001,000 cells should be rejected")
}
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
}}
if err := twoSheets.checkCellBudget(); err == nil {
t.Fatal("1.2M cells across two sheets should be rejected")
}
}
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
// per-range cap can't stop many ranges from summing past the matrix ceiling.
func TestBatchStampAggregateCap(t *testing.T) {
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
t.Fatalf("aggregate == cap should pass, got: %v", err)
}
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
t.Fatal("aggregate over cap should be rejected")
}
}
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
// ranges and expects the shared validateDropdownRanges cap to reject it.
func TestBatchFanoutRangeCountCap(t *testing.T) {
ranges := make([]string, maxBatchRanges+1)
for i := range ranges {
ranges[i] = "sheet1!A1"
}
rangesJSON, _ := json.Marshal(ranges)
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
"--url", testURL,
"--ranges", string(rangesJSON),
"--font-weight", "bold",
"--dry-run",
})
requireValidation(t, err, "at most")
}
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
func TestBatchOperationsCountCap(t *testing.T) {
ops := make([]interface{}, maxBatchOperations+1)
for i := range ops {
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
}
_, err := translateBatchOperations(ops, testURL)
if err == nil || !strings.Contains(err.Error(), "at most") {
t.Fatalf("expected operations count cap error, got: %v", err)
}
}
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
// 5.3M allocs is now rejected up front, allocating only the error string.
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
b.Fatal("expected rejection")
}
}
}
func BenchmarkDownloadReader_StringCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, false) }
func BenchmarkDownloadReader_StringCopy_16MB(b *testing.B) { benchDownloadReader(b, 16<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_16MB(b *testing.B) {
benchDownloadReader(b, 16<<20, false)
}

View File

@@ -70,7 +70,6 @@ func shortcutList() []common.Shortcut {
return []common.Shortcut{
// lark_sheet_workbook
WorkbookInfo,
RevisionGet,
SheetCreate,
SheetDelete,
SheetRename,
@@ -96,9 +95,6 @@ func shortcutList() []common.Shortcut {
DimUngroup,
DimMove,
// lark_sheet_changeset
ChangesetGet,
// lark_sheet_read_data
CellsGet,
CsvGet,
@@ -109,9 +105,6 @@ func shortcutList() []common.Shortcut {
CellsSearch,
CellsReplace,
// lark_sheet_formula_verify
FormulaVerify,
// lark_sheet_write_cells
CellsSet,
CellsSetStyle,
@@ -155,10 +148,5 @@ func shortcutList() []common.Shortcut {
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
// lark_sheet_history
HistoryList,
HistoryRevert,
HistoryRevertStatus,
}
}

View File

@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
}
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {

View File

@@ -1,77 +1,23 @@
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。
## 路由优先级(先判断是不是审批,再选命令)
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
### 明确归 `lark-approval` 的高优先级语义
出现以下任一语义时,优先走 `lark-approval`
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 | 按需读取 reference |
|---|---|---------------------------------------------------------------------------------|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读 | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `approvals search` |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
| 发起原生审批实例 | `instances create` |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
处理链:
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,`instances get` → 执行操作
## 执行原则(减少误路由、误重试和无效消耗)
### 1) 先拿最小必要信息,再执行
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
### 2) 已知对象时直达动作
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
### 3) 错误码驱动,而不是盲目重试
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控最多重试1次
## 写操作失败处理1395001 决策树
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
1. **先停止盲目重试**不要连续重复提交相同写操作最多重试1次
2. 优先从以下角度解释:
- 任务可能已被他人处理
- 单据状态已变化,当前动作已不再允许
- 当前用户已不具备该任务的操作资格
- 当前节点或单据状态不支持该操作
3. 如需确认,只补 **一次** 状态查询(`tasks query``instances get`),不要陷入 query/write 循环
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节`instances get` → 执行操作
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
@@ -81,6 +27,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
## 不在本 skill 范围
## 发起原生审批
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
发起审批属于高风险写操作,按下表处理:
| 规则 | 处理 |
|---|---|
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form``node_approver_list``node_cc_list` |
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code``instance_link` |

View File

@@ -8,83 +8,28 @@ metadata:
cliHelp: "lark-cli approval --help"
---
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段;**references 是第一信息源**,只有在 reference 未覆盖的原生 / 高级场景下,才额外用 `lark-cli ... --help``lark-cli schema` 等方式补充确认字段。
## 路由优先级(先判断是不是审批,再选命令)
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
### 明确归 `lark-approval` 的高优先级语义
出现以下任一语义时,优先走 `lark-approval`
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 | 按需读取 reference |
|---|---|---------------------------------------------------------------------------------|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读 | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `approvals search` |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
| 发起原生审批实例 | `instances create` |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
处理链:
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,`instances get` → 执行操作
## 执行原则(减少误路由、误重试和无效消耗)
### 1) 先拿最小必要信息,再执行
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
### 2) 已知对象时直达动作
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
### 3) 错误码驱动,而不是盲目重试
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控最多重试1次
## 写操作失败处理1395001 决策树
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
1. **先停止盲目重试**不要连续重复提交相同写操作最多重试1次
2. 优先从以下角度解释:
- 任务可能已被他人处理
- 单据状态已变化,当前动作已不再允许
- 当前用户已不具备该任务的操作资格
- 当前节点或单据状态不支持该操作
3. 如需确认,只补 **一次** 状态查询(`tasks query``instances get`),不要陷入 query/write 循环
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节`instances get` → 执行操作
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
@@ -94,6 +39,18 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
## 发起原生审批
发起审批属于高风险写操作,按下表处理:
| 规则 | 处理 |
|---|---|
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form``node_approver_list``node_cc_list` |
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code``instance_link` |
## 不在本 skill 范围
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)

View File

@@ -1,128 +0,0 @@
# approval approvals get
获取单个审批定义详情(用户级只读操作)。适合在发起审批实例前,先确认审批名称、表单控件结构、选项值范围以及流程节点信息。
需要的 scopes: ["approval:approval:read"]
## 命令
```bash
# 按 approval_code 查询审批定义详情
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
# 表格格式输出,便于快速浏览顶层字段
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
| `approval_code` | 是 | 审批定义 Code通常来自 `approval approvals search` 的结果 |
| `locale` | 否 | 返回语言,例如 `zh-CN``en-US``ja-JP` |
| `--as user` | 否 | 建议显式指定用户身份;审批定义详情通常按当前用户可见范围读取 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 常见输入来源
如果你已经有 `approval_code`,可直接查询:
```bash
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
```
如果你还没有 `approval_code`,先搜索可发起审批定义:
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
```
## 输出重点字段
返回结果中,优先关注以下字段:
| 字段 | 说明 |
|------|------|
| `approval_code` | 审批定义 Code |
| `approval_name` | 审批定义名称;确认是不是用户想发起的那张单 |
| `form` | 表单定义快照;用于识别控件 `id``type`、选项值范围、明细子控件结构 |
| `node_list` | 流程节点列表;用于识别节点 key、是否需要补充审批人、是否允许多人 |
## form 的使用重点
`form` 最重要的作用是帮助 agent **识别怎么组装 `instances.create.data.form`**,而不是直接把它原样提交出去。
重点看:
| 字段 / 结构 | 说明 |
|------|------|
| `form[].id` | 控件 ID后续创建实例时必须使用 |
| `form[].type` | 控件类型,例如 `input``date``radio``checkbox``fieldList` |
| `form[].value` / 选项定义 | 用来识别可选值范围、默认值或选项值 |
| 明细 / 子控件结构 | 用于识别 `fieldList`、控件组等复杂控件的子字段结构 |
**注意:`approvals.get.form` 不是 `instances.create` 可直接复用的 payload 模板。** 它是“定义快照”,主要用于识别字段结构与选项值范围。
## node_list 的使用重点
`node_list` 主要用于后续决定是否要补 `node_approver_list` / `node_cc_list`
重点看:
| 字段 | 说明 |
|------|------|
| `node_list[].custom_node_id` | 自定义节点标识;后续补节点参数时优先作为 key |
| `node_list[].node_id` | 节点 ID若没有 `custom_node_id`,通常退回用它做 key |
| `node_list[].need_approver` | 是否要求发起人补充审批人 |
| `node_list[].approver_chosen_multi` | 是否允许为该节点选择多个审批人 |
## 使用建议
- **这是发起原生审批实例前的必要只读步骤。** 推荐固定走:`approvals search` -> `approvals get` -> `instances create`
- **如果用户已经明确给了 `approval_code`,直接用这个命令。** 不必再走 `approvals search`
- **先确认 `approval_name`。** 避免把相似名称的审批定义搞混。
- **先用 `form` 识别控件结构,再组装创建 payload。** 不要在未看详情时猜控件 `id``type` 或选项值。
- **先用 `node_list` 看是否需要补审批人。** 若某节点 `need_approver=true`,创建实例时通常要补 `node_approver_list`
- **`node_list` 的 key 优先取 `custom_node_id`。** 若不存在,再使用 `node_id`
- **`approver_chosen_multi=false` 时,一个节点通常只能补一个审批人。**
## 输出与后续操作
读取定义详情后,常见下一步:
```bash
# 发起原生审批实例
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
```
如果需要进一步理解控件取值与节点参数,优先参考:
- `lark-approval-instance-form-control-parameters.md`
- `lark-approval-instance-value-sourcing.md`
- `lark-approval-initiate.md`
## 结果整理方式
**将结果整理为“审批定义概览 + 表单结构摘要 + 节点要求摘要”。**
建议输出成下面这种结构:
```text
审批定义:请假申请
approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
表单控件摘要:
- leave_type: radio可选值 [annual_leave, sick_leave]
- reason: textarea
- start_end: dateInterval
节点要求摘要:
- manager_nodeneed_approver=trueapprover_chosen_multi=false
- hr_nodeneed_approver=false
```

View File

@@ -1,103 +0,0 @@
# approval approvals search
搜索**当前用户可发起**的审批定义launchable approvals。只读操作不会创建审批实例。
需要的 scopes: ["approval:approval:read"]
## 命令
```bash
# 按关键词搜索可发起审批定义
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
# 使用 page_token 翻页
lark-cli approval approvals search --data '{"keyword":"请假", "page_token":"example_page_token"}' --as user
# 表格格式输出,便于快速浏览候选定义
lark-cli approval approvals search --data '{"keyword":"出差"}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 查询参数,使用 JSON 传入 |
| `keyword` | 是 | 搜索关键词,例如 `请假``报销``出差``采购` |
| `locale` | 否 | 返回语言,例如 `zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
| `--as user` | 否 | 建议显式指定用户身份;“可发起审批定义”是面向当前用户的查询 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 这个命令解决什么问题
当用户只有自然语言意图,还没有 `approval_code` 时,先用它把“可发起的审批定义候选项”找出来。
典型场景:
- “帮我找一下请假审批”
- “有哪些可以发起的报销单?”
- “先搜一下出差审批,再帮我提单”
## 输出重点字段
返回结果里,优先关注以下字段:
| 字段 | 说明 |
|------|------|
| `approval_code` | 审批定义 Code后续 `approvals get``instances create` 都要用它 |
| `approval_name` | 审批定义名称;给用户做候选选择时最关键 |
| `is_external` | 是否为三方审批定义;`true` 表示不能走原生 `instances.create` |
| `create_link` | 三方审批定义的发起链接;`is_external=true` 时优先返回给用户 |
## 使用规则
- **这是发起审批工作流的第一步。** 标准顺序是:`approvals search` -> `approvals get` -> `instances create`
- **搜索结果为空时,不要猜。** 直接告诉用户当前关键词下没有可发起定义,并建议用户换关键词。
- **命中多个结果时,不要替用户拍板。** 先把候选定义列出来,让用户选择目标审批定义。
- **`is_external=true` 时不要调用 `approval instances create`。** 这类定义属于三方审批,优先返回 `create_link` 并说明需要通过链接发起。
- **只有 `is_external=false` 的原生定义,才继续 `approvals get`。**
- **如果用户已经明确给出 `approval_code`,不要再 search。** 直接执行 `approval approvals get`
## 结果整理方式
**将结果整理为候选清单,优先展示“名称 + approval_code + 是否三方定义 + 下一步建议”。**
建议输出成下面这种结构:
```text
找到 3 个可发起审批定义:
1. 请假申请
- approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
- is_external: false
- next: 可继续读取 definitions 详情approvals get
2. 差旅报销
- approval_code: 99887766-xxxx
- is_external: true
- next: 返回 create_link引导用户通过链接发起
```
## 常见后续操作
### 1用户选中了某个定义继续查看详情
```bash
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
```
### 2确认是原生定义后再准备发起审批实例
```bash
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
```
### 3确认是三方定义时直接返回链接
`is_external=true` 时,优先向用户返回 `create_link`,说明该审批需在三方系统或跳转页面中发起,而不是通过原生 `instances.create`

View File

@@ -2,15 +2,14 @@
## 执行摘要
- **原生审批提单如果用户未明确给出 `approval_code`必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`
- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`** 不要跳过 `get` 直接拼请求。
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
- **先读控件参数 reference 和值来源 reference读本文里的创建参数规则。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md)。
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id``type`、选项值范围和明细子控件结构;真正的 `instances create --data.form` 中,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
- **节点参数只从 `node_list`本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。
- **先读控件参数 reference 和值来源 reference`schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id``type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
- **节点参数只从 `node_list` `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`
- **创建实例前先确认。** `approval instances create` 是写操作,执行前,让用户确认最终定义、表单值和节点参数;真正执行时显式传 `--yes`
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`
## 适用场景
@@ -21,10 +20,11 @@
## 严禁行为
- **严禁在未先阅读本文中的创建参数规则、[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 的情况下直接提单。**
- **严禁跳过 `approvals.get`。** 未拿到 `form``node_list` 前,不得调用 `instances create`
- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
- **严禁跳过 `approvals.get`。** 未拿到 `form``node_list` 前,不得调用 `instances.create`
- **严禁把姓名直接写进 `node_approver_list``node_cc_list` 或表单人员控件。** 必须先转成 `open_id`
- **严禁对三方定义调用 `instances create`。**
- **严禁对三方定义调用 `instances.create`。**
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -33,9 +33,10 @@
### 1. 搜索可发起审批定义
先搜索定义:
`schema` 看参数,再搜索定义:
```bash
lark-cli schema approval.approvals.search
lark-cli approval approvals search --data '{"keyword":"请假"}'
```
@@ -43,7 +44,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
- 若结果为空,告诉用户当前关键词下没有可发起定义。
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances create`
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`
- 只有 `is_external=false` 的原生定义才继续下一步。
### 2. 获取审批定义详情
@@ -51,6 +52,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
拿到 `approval_code` 后,读取定义详情:
```bash
lark-cli schema approval.approvals.get
lark-cli approval approvals get \
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
```
@@ -61,30 +63,12 @@ lark-cli approval approvals get \
- `form`: 表单定义快照,用于识别控件 `id``type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
### 3. 创建请求参数速查
### 3. 组装 `form`
输入参数如下
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则
| 参数 | 必填 | 说明 |
|---|---|---|
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
| `approval_code` | 是 | 审批定义 Code必须先通过 `approvals search` / `approvals get` 确认 |
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;涉及人员类 ID 时建议显式传 `open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
### 4. 组装 `form`
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
- 提交时必须至少保证每个控件的 `id``type``value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
- 提交时必须至少保证每个控件的 `id``type``value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
- `contact``department``fieldList``dateInterval``amount``telephone``document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
@@ -116,7 +100,7 @@ lark-cli approval approvals get \
- `input` / `textarea`: `value` 是字符串
- `date`: `value` 是 RFC3339 时间字符串
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id`
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 option.value关联外部选项时传 `options.id`
- `checkbox` / `checkboxV2`: `value` 是选项值数组
- `number`: `value` 是数字
- `amount`: `value` 是数字,还要带 `currency`
@@ -145,7 +129,7 @@ lark-cli approval approvals get \
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
- 不要把控件组整体当成普通字符串或扁平对象提交
### 5. 组装节点参数
### 4. 组装节点参数
`node_list` 推导节点参数:
@@ -155,13 +139,13 @@ lark-cli approval approvals get \
-`approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
### 6. 创建审批实例
### 5. 创建审批实例
创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"]
确认最终表单值和节点参数后再执行:
先看 `schema`,确认最终结构后再执行:
```bash
lark-cli schema approval.instances.create
lark-cli approval instances create \
--data '{
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
@@ -173,8 +157,6 @@ lark-cli approval instances create \
}
]
}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
@@ -188,7 +170,7 @@ lark-cli approval instances create \
优先级固定如下:
1. 本文中的创建请求参数、节点参数和返回结果说明:决定 `instances create` 要传哪些字段、怎么执行、成功后回什么
1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传
2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
@@ -202,8 +184,8 @@ lark-cli approval instances create \
|---|---|
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
| 已经拿到 `approval_code` | 直接 `approvals.get` |
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` |
| `is_external=true` | 返回 `create_link`,不要调 `instances create` |
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
## 返回结果
@@ -212,13 +194,3 @@ lark-cli approval instances create \
- `approval_name`
- `instance_code`
- `instance_link`
建议整理为下面这种结构:
```text
审批已创建成功:
- approval_name: 请假申请
- instance_code: 19EAC829-F1CB-527F-BE2A-1330422E60C0
- instance_link: https://...
```

View File

@@ -6,14 +6,14 @@
阅读顺序固定如下:
1. [`lark-approval-initiate.md`](./lark-approval-initiate.md) 中的创建请求参数、节点参数和返回结果说明
1. `lark-cli schema approval.instances.create`
2. `approval approvals get` 返回的 `form` / `node_list`
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
4. 本文
## 总原则
- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
- `approvals.get.form` 决定控件 `id``type`、选项值范围、子控件结构。
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。

View File

@@ -1,78 +0,0 @@
# approval instances cancel
撤回一个已发起的审批实例(用户级写操作)。通常先通过 `instances initiated``tasks query``instances get` 确认目标审批实例,拿到 `instance_code` 后再执行撤回。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要撤回该审批实例且目标实例无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:instance:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval instances cancel \
--data '{"instance_code":"<INSTANCE_CODE>"}' \
--as user \
--dry-run
# 撤回一个审批实例
lark-cli approval instances cancel \
--data '{"instance_code":"<INSTANCE_CODE>"}' \
--as user \
--yes
# 通过文件传入请求体
lark-cli approval instances cancel \
--data @./cancel-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `instances initiated``tasks query``instances get` 获取 |
| `--as user` | 否 | 建议显式指定用户身份;审批实例撤回通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
如果你要找“我发起的审批实例”,可先查询已发起列表:
```bash
lark-cli approval instances initiated --params '{"page_size":20}' --as user
```
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `instances[].instance_code` | 审批实例 Code撤回时必须提供 |
| `tasks[].instance_code` | 审批任务关联的审批实例 Code也可作为撤回输入 |
| `tasks[].instance_status` | 审批实例状态;可用于判断是否仍处于可撤回阶段 |
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **撤回的是审批实例,不是单个任务**`instances cancel` 只需要 `instance_code`,不需要 `task_id`
- **优先确认实例是否仍可撤回**:已经通过、已拒绝、已撤销或已终止的实例通常不适合继续撤回。
- **优先从 `instances initiated` 获取目标实例**:因为撤回通常针对“我发起的审批”,这个入口最直接。
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个待办/已办上下文进入时,这样更方便。
- **先 `--dry-run` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。

View File

@@ -1,105 +0,0 @@
# approval instances cc
给一个审批实例追加抄送人(用户级写操作)。通常先通过 `instances initiated``tasks query``instances get` 确认目标审批实例,拿到 `instance_code` 后,再提供抄送人的用户 ID 执行抄送。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要抄送该审批实例且目标实例、抄送对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:instance:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给项目 owner 了解进展"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--dry-run
# 按 open_id 抄送一个人
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给你知悉"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 一次抄送多个人
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx","ou_yyy"],"comment":"请相关同学同步关注"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 按 user_id 抄送
lark-cli approval instances cc \
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["123456789"],"comment":"抄送给财务负责人"}' \
--params '{"user_id_type":"user_id"}' \
--as user \
--yes
# 通过文件传入请求体
lark-cli approval instances cc \
--data @./cc-body.json \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `instances initiated``tasks query``instances get` 获取 |
| `cc_user_ids` | 是 | 抄送人的用户 ID 数组;需要和 `user_id_type` 保持一致 |
| `comment` | 否 | 抄送留言,例如 `抄送给你知悉``请同步关注该审批进展` |
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON用于声明 `cc_user_ids` 内用户 ID 的类型 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;未显式指定时要特别确认抄送人的 ID 类型 |
| `--as user` | 否 | 建议显式指定用户身份;审批实例抄送通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
如果你要找“我发起的审批实例”,可先查询已发起列表:
```bash
lark-cli approval instances initiated --params '{"page_size":20}' --as user
```
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `instances[].instance_code` | 审批实例 Code抄送时必须提供 |
| `tasks[].instance_code` | 审批任务关联的审批实例 Code也可作为抄送输入 |
| `tasks[].title` | 任务标题,可用于确认是否是要操作的那个审批 |
| `tasks[].instance_status` | 审批实例状态;可用于判断当前审批是否仍处于进行中 |
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID再执行抄送。
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **抄送的是审批实例,不是单个任务**`instances cc` 只需要 `instance_code`,不需要 `task_id`
- **`cc_user_ids``user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
- **`cc_user_ids` 是数组**:即使只抄送一个人,也要按数组形式传入。
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
- **优先从 `instances initiated` 获取目标实例**:因为抄送常见于“我发起的审批”场景,这个入口最直接。
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个审批上下文进入时,这样更方便。
- **`comment` 建议简洁明确**:例如 `抄送给你知悉``请同步关注审批进展`。避免过长或模糊描述。
- **先 `--dry-run` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。

View File

@@ -1,145 +0,0 @@
# approval instances get
获取单个审批实例详情(用户级只读操作)。适合在执行 approve / reject / transfer / rollback / cancel / cc / remind 之前,先查看审批表单、当前节点、任务列表、审批动态和整体状态。
需要的 scopes: ["approval:instance:read"]
## 命令
```bash
# 按实例 Code 查询详情
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
# 表格格式输出,便于快速浏览顶层字段
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code |
| `locale` | 否 | 返回语言,例如 `zh-CN``en-US``ja-JP` |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批实例详情查询通常应使用用户身份 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 常见输入来源
如果你已经有实例 Code可直接查询
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
如果你还没有实例 Code可先从以下命令获取
```bash
# 查询我发起的审批实例
lark-cli approval instances initiated --params '{"page_size":20}' --as user
# 或从任务列表里拿到关联实例 Code
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
## 输出重点字段
返回结果中常见字段:
| 字段 | 说明 |
|------|------|
| `instance_code` | 审批实例 Code |
| `serial_number` | 审批单编号 |
| `definition_code` | 审批定义 Code |
| `definition_name` | 审批名称 |
| `user_id` | 发起审批的用户 ID |
| `department_id` | 发起人所在部门 ID |
| `status` | 审批实例状态见下方“status 枚举” |
| `reverted` | 单据是否已被撤销 |
| `start_time` | 审批创建时间 |
| `end_time` | 审批完成时间,未完成时通常为 `0` |
| `form` | 表单数据JSON 字符串 |
| `current_nodes` | 当前审批节点列表 |
| `tasks` | 审批任务列表 |
| `operation_records` | 审批动态,例如通过、拒绝、转交、加签、回退、撤回、抄送 |
| `comments` | 评论列表 |
## status 枚举
| 值 | 含义 |
|----|------|
| `PENDING` | 审批中 |
| `APPROVED` | 已通过 |
| `REJECTED` | 已拒绝 |
| `CANCELED` | 已撤回 |
| `DELETED` | 已删除 |
## current_nodes 重点字段
`current_nodes` 常用于判断审批流当前卡在哪一层:
| 字段 | 说明 |
|------|------------------------------------------|
| `current_nodes[].node_id` | 当前审批节点 ID |
| `current_nodes[].node_name` | 当前审批节点名称 |
| `current_nodes[].type` | 审批方式:`AND` 会签、`OR` 或签、`SEQUENTIAL` 依次审批等 |
| `current_nodes[].approvers[].task_id` | 当前审批人关联任务 ID |
| `current_nodes[].approvers[].user_id` | 当前审批人用户 ID |
## tasks 重点字段
`tasks` 常用于把实例和具体审批任务关联起来:
| 字段 | 说明 |
|------|------|
| `tasks[].id` | 审批任务 ID |
| `tasks[].node_id` | 任务所属节点 ID |
| `tasks[].node_name` | 任务所属节点名称 |
| `tasks[].user_id` | 审批人用户 ID |
| `tasks[].status` | 任务状态:`PENDING``APPROVED``REJECTED``TRANSFERRED``DONE` |
| `tasks[].start_time` | 任务开始时间 |
| `tasks[].end_time` | 任务完成时间 |
## operation_records 重点字段
`operation_records` 常用于审计审批过程:
| 字段 | 说明 |
|------|------|
| `operation_records[].type` | 事件类型,如 `PASS``REJECT``TRANSFER``ROLLBACK``CANCEL``CC` |
| `operation_records[].create_time` | 事件发生时间 |
| `operation_records[].user_id` | 触发该事件的用户 ID |
| `operation_records[].task_id` | 关联任务 ID |
| `operation_records[].node_id` | 关联节点 ID |
| `operation_records[].comment` | 理由 / 备注 |
| `operation_records[].cc_user_ids` | 被抄送人列表(抄送事件时) |
## 使用建议
- **这是最适合做“详情确认”的只读命令**:当你已经拿到 `instance_code`,需要确认表单、当前节点、任务状态、审批动态时,优先使用它。
- **在执行写操作前先看详情**:例如做 `tasks rollback` 前确认可退回节点,做 `instances cancel` 前确认实例状态,做 `tasks remind` 前确认当前任务是否仍待处理。
- **`form` 是 JSON 字符串**:调用方通常还需要再解析一层,才能拿到表单字段值。
- **`current_nodes``tasks` 可以联动看**:前者看“当前卡在哪个节点”,后者看“每个任务目前由谁处理、状态如何”。
- **`operation_records` 适合做时间线回溯**:例如排查谁转交过、谁加签过、什么时候撤回或抄送过。
- **优先显式传 `locale``user_id_type`**:这样 agent 更容易理解返回文本和 ID 语义,减少歧义。
## 输出与后续操作
读取详情后,常见下一步:
```bash
# 同意审批任务
lark-cli approval tasks approve --data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>"}' --as user --yes
# 撤回审批实例
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
# 催办审批任务
lark-cli approval tasks remind --data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"]}' --as user --yes
```

View File

@@ -1,122 +0,0 @@
# approval instances initiated
查询当前用户已发起的审批实例列表(用户级只读操作)。适合在需要查看“我发起了哪些审批”、筛选某类审批定义、获取 `instance_code` 供后续 `instances get` / `instances cancel` / `instances cc` 等命令使用时调用。
需要的 scopes: ["approval:instance:read"]
## 命令
```bash
# 查询我发起的审批列表
lark-cli approval instances initiated --params '{"page_size":20}' --as user
# 只看某个审批定义下我发起的实例
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
# 使用 page_token 翻页
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
# 表格格式输出,便于快速浏览
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
# 预览 API 调用,不执行
lark-cli approval instances initiated --params '{"page_size":20}' --as user --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
| `definition_code` | 否 | 审批定义 Code用于只查看某个审批定义下我发起的实例 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id` |
| `--as user` | 否 | 建议显式指定用户身份;已发起审批列表查询通常应使用用户身份 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 输出重点字段
返回结果中常见字段:
| 字段 | 说明 |
|------|------|
| `count` | 列表计数,只在第一页返回;大于等于 100 个实例时返回 `99` |
| `has_more` | 是否还有更多数据 |
| `page_token` | 下一页翻页 Token |
| `instances[].instance_code` | 审批实例 Code后续查询详情或执行撤回 / 抄送时通常需要 |
| `instances[].definition_code` | 审批定义 Code |
| `instances[].definition_name` | 审批定义名称 |
| `instances[].definition_group_id` | 审批定义分组 ID |
| `instances[].definition_group_name` | 审批定义分组名称 |
| `instances[].initiator` | 发起人 ID |
| `instances[].initiator_name` | 发起人姓名 |
| `instances[].instance_status` | 审批实例状态见下方“instance_status 枚举” |
| `instances[].instance_external_id` | 第三方审批实例 ID仅第三方审批实例存在 |
| `instances[].link` | 三方审批跳转链接 |
| `instances[].summaries` | 摘要字段列表 |
## instance_status 枚举
| 值 | 含义 |
|----|------|
| `0` | 无流程状态,不展示对应标签 |
| `1` | 流程实例流转中 |
| `2` | 已通过 |
| `3` | 已拒绝 |
| `4` | 已撤销 |
| `5` | 已终止 |
## 常见使用场景
### 1) 找到我要操作的审批实例
```bash
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
```
拿到 `instances[].instance_code` 后,可继续:
```bash
# 查看审批实例详情
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
# 撤回审批实例
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
```
### 2) 只看某类审批
```bash
lark-cli approval instances initiated \
--params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' \
--as user
```
## 使用建议
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
- **结果很多时优先 `--format table`**:适合人工快速浏览。
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
- **摘要字段 `summaries` 很适合做列表预览**:当审批标题不够明确时,可结合摘要值帮助识别目标实例。
## 输出与后续操作
拿到列表后,常见下一步:
```bash
# 查看单个审批实例详情
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
# 撤回审批实例
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
# 给审批实例追加抄送人
lark-cli approval instances cc --data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["<USER_ID>"]}' --params '{"user_id_type":"open_id"}' --as user --yes
```

View File

@@ -1,120 +0,0 @@
# approval tasks add_sign
给一个审批任务加签(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,确认目标任务后,再提供被加签人的用户 ID、加签方式等参数执行加签。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要对该审批任务加签且目标任务、加签对象、加签方式都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"前加签给财务复核"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--dry-run
# 前加签(需要 approval_method
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"请先补充审核"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 后加签(需要 approval_method
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":2,"add_sign_user_ids":["ou_xxx","ou_yyy"],"approval_method":2,"comment":"当前审批完成后请两位继续审核"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 并加签(常见场景可不传 approval_method
lark-cli approval tasks add_sign \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":3,"add_sign_user_ids":["123456789"],"comment":"并加签给项目 owner"}' \
--params '{"user_id_type":"user_id"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment 或较多加签人
lark-cli approval tasks add_sign \
--data @./add-sign-body.json \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `add_sign_type` | 是 | 加签类型:`1` 前加签、`2` 后加签、`3` 并加签 |
| `add_sign_user_ids` | 是 | 被加签人 ID 数组;需要和 `user_id_type` 保持一致 |
| `approval_method` | 否 | 审批方式:`1` 或签、`2` 会签、`3` 依次审批;**仅在前加签、后加签时需要填写** |
| `comment` | 否 | 审批意见或加签说明,例如 `前加签给财务复核``请项目 owner 一并确认` |
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON用于声明 `add_sign_user_ids` 内用户 ID 的类型 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;未显式指定时要特别确认被加签人的 ID 类型 |
| `--as user` | 否 | 建议显式指定用户身份;审批加签通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 枚举说明
### add_sign_type
| 值 | 含义 |
|----|------|
| `1` | 前加签 |
| `2` | 后加签 |
| `3` | 并加签 |
### approval_method
| 值 | 含义 | 适用场景 |
|----|------|----------|
| `1` | 或签 | 前加签 / 后加签 |
| `2` | 会签 | 前加签 / 后加签 |
| `3` | 依次审批 | 前加签 / 后加签 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / transfer / rollback / add_sign 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;加签前建议先检查 |
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID再执行加签。
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行加签操作。
- **`add_sign_user_ids``user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
- **`add_sign_type` 要和业务意图一致**:前加签是在当前审批前插入审批人,后加签是在当前审批后追加审批人,并加签则是增加并行审批人。
- **前加签 / 后加签要补 `approval_method`**:不要遗漏,否则请求可能无法准确表达审批方式。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 add_sign 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行处理动作,加签前应谨慎验证。
- **`comment` 建议写明加签原因**:例如 `增加财务复核``增加项目 owner 并行确认`,方便相关人员理解上下文。
- **先 `--dry-run` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。

View File

@@ -1,81 +0,0 @@
# approval tasks approve
同意一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,必要时再用 `instances get` 查看详情,然后再执行同意。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确同意审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks approve \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
--as user \
--dry-run
# 同意审批任务,并附带审批意见
lark-cli approval tasks approve \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
--as user \
--yes
# 需要回填表单时,传入 form按当前命令定义form 为字符串化 JSON
lark-cli approval tasks approve \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意并补充信息","form":"[{\"id\":\"user_name\",\"type\":\"input\",\"value\":\"Alice\"}]"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment / form
lark-cli approval tasks approve \
--data @./approve-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `comment` | 否 | 审批意见,例如 `同意``已确认` |
| `form` | 否 | 表单数据;按当前命令定义,字段类型为 `string`,通常传字符串化 JSON仅在审批动作需要同时回填表单时使用 |
| `--as user` | 否 | 建议显式指定用户身份;审批同意通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的两个字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行同意操作。
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 approve 的输入来源。
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 同意/拒绝。
- **`comment` 建议简洁明确**:例如 `同意``同意,信息已核对`。没有审批意见要求时可省略。
- **`form` 只在确有需要时传**:大多数简单同意场景只传 `instance_code``task_id`、可选 `comment` 即可。
- **先 `--dry-run` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。

View File

@@ -1,76 +0,0 @@
# approval tasks query
查询当前用户的审批任务列表,可用于查看待办、已办、知会等分组。只读操作,不会修改审批状态。
需要的 scopes: ["approval:task:read"]
## 命令
```bash
# 查询待办审批
lark-cli approval tasks query --params '{"topic":"1"}' --as user
# 查询已办审批
lark-cli approval tasks query --params '{"topic":"2"}' --as user
# 使用 page_token 翻页
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
# 表格格式输出,便于快速浏览
lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
| `topic` | 是 | 任务分组主题见下方“topic 枚举” |
| `definition_code` | 否 | 审批定义 Code用于仅查询某个审批定义下的任务 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批任务查询通常应使用用户身份 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## topic 枚举
| 值 | 含义 |
|----|------|
| `1` | 待办审批 |
| `2` | 已办审批 |
| `17` | 未读知会 |
| `18` | 已读知会 |
## 输出重点字段
返回结果中常见字段:
| 字段 | 说明 |
|------|------|
| `count` | 列表计数,只在第一页返回;当任务数大于等于 100 时返回 `99` |
| `has_more` | 是否还有更多数据 |
| `page_token` | 下一页翻页 Token |
| `tasks[].task_id` | 任务 ID全局唯一 |
| `tasks[].instance_code` | 审批实例 Code后续执行 approve / reject / rollback 等操作时通常需要与 `task_id` 成对使用 |
| `tasks[].title` | 任务标题 |
| `tasks[].status` | 任务状态:`1` 待办、`2` 已办、`17` 未读、`18` 已读、`33` 处理中、`34` 撤回 |
| `tasks[].topic` | 任务所属分组主题 |
| `tasks[].instance_status` | 审批实例状态:`0` 无状态、`1` 流转中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 已终止 |
| `tasks[].definition_code` | 审批定义 Code |
| `tasks[].definition_name` | 审批定义名称 |
| `tasks[].initiator` | 发起人 ID |
| `tasks[].initiator_name` | 发起人姓名 |
| `tasks[].summaries` | 表单摘要字段列表 |
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
| `tasks[].user_id` | 任务所属用户 ID |
## 使用建议
- 常见处理链:先用 `tasks query` 拿到 `task_id``instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`
- 如果你只想看“已发起的审批实例”,使用 `instances initiated``tasks query` 更适合围绕“任务分组”来拉取列表。
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`
- 当结果量较大时,优先使用 `--format table` 提升可读性。

View File

@@ -1,73 +0,0 @@
# approval tasks reject
拒绝一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,必要时再用 `instances get` 查看详情,然后再执行拒绝。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要拒绝该审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks reject \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝"}' \
--as user \
--dry-run
# 拒绝审批任务,并附带审批意见
lark-cli approval tasks reject \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝,信息不完整"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment
lark-cli approval tasks reject \
--data @./reject-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `comment` | 否 | 审批意见,例如 `拒绝``拒绝,信息不完整` |
| `--as user` | 否 | 建议显式指定用户身份;审批拒绝通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的两个字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行拒绝操作。
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 reject 的输入来源。
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 同意/拒绝。
- **`comment` 建议写清拒绝原因**:例如 `拒绝,缺少合同附件``拒绝,预算字段填写不完整`。这有助于发起人理解原因并补充材料。
- **先 `--dry-run` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。

View File

@@ -1,82 +0,0 @@
# approval tasks remind
对审批实例中的指定任务发起催办(用户级写操作)。通常先通过 `tasks query` 找到待办任务,拿到 `instance_code` 和要催办的 `task_ids`,必要时再用 `instances get` 查看详情,然后执行催办。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要催办该审批且目标实例、目标任务都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:instance:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks remind \
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快处理"}' \
--as user \
--dry-run
# 催办单个审批任务
lark-cli approval tasks remind \
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快审批该单据"}' \
--as user \
--yes
# 同一实例下催办多个任务
lark-cli approval tasks remind \
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID_1>","<TASK_ID_2>"],"comment":"请相关审批人尽快处理"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment 或多个 task_ids
lark-cli approval tasks remind \
--data @./remind-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances get` 获取 |
| `task_ids` | 是 | 被催办的任务 ID 数组;应与 `instance_code` 属于同一审批实例 |
| `comment` | 否 | 催办说明,例如 `请尽快处理``该单据较急,请优先审批` |
| `--as user` | 否 | 建议显式指定用户身份;审批催办通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code催办时必须提供 |
| `tasks[].task_id` | 审批任务 ID放入 `task_ids` 数组中 |
| `tasks[].title` | 任务标题,可用于确认催办对象是否正确 |
| `tasks[].status` | 任务状态;一般优先催办仍处于待处理状态的任务 |
如需进一步确认当前审批流、节点和人员信息,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_ids` 要对应同一个审批实例**:不要把不同实例下的任务 ID 混在同一次催办请求中。
- **`task_ids` 是数组**:即使只催办一个任务,也要按数组形式传入。
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 remind 的输入来源。
- **催办前先确认任务仍需处理**:已经审批完成、已撤回或已终止的任务一般不适合继续催办。
- **`comment` 建议简洁且明确**:例如 `该单据较急,请优先审批``请今天内处理`。避免过长或模糊描述。
- **先 `--dry-run` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。

View File

@@ -1,83 +0,0 @@
# approval tasks rollback
将一个审批任务退回到指定节点(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,再结合实例详情确认可退回的目标节点 `node_ids`,最后执行退回。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要退回该审批且目标任务、退回节点都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"退回补充材料"}' \
--as user \
--dry-run
# 退回到单个节点
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"请补充附件后重新提交"}' \
--as user \
--yes
# 传多个候选节点 ID以实际审批定义支持情况为准
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment 或较多 node_ids
lark-cli approval tasks rollback \
--data @./rollback-body.json \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交``预算说明不完整,请补充` |
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / transfer / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;退回前建议先检查 |
如需确认流程节点、当前进度和可退回位置,可先查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
- **`comment` 建议写清退回原因**:例如 `附件缺失,请补齐后重新提交``费用说明不完整,请补充明细`,方便发起人或上一步处理人理解原因。
- **先 `--dry-run` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。

View File

@@ -1,91 +0,0 @@
# approval tasks transfer
转交一个审批任务给其他用户处理(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id``instance_code`,确认目标任务后,再提供被转交人的用户 ID 执行转交。
> [!CAUTION]
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要转交该审批且目标任务、转交对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
需要的 scopes: ["approval:task:write"]
## 命令
```bash
# 先预览请求,不实际执行
lark-cli approval tasks transfer \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"请你继续处理"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--dry-run
# 按 open_id 转交审批任务
lark-cli approval tasks transfer \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"转交给你处理"}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
# 按 user_id 转交审批任务
lark-cli approval tasks transfer \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"123456789","comment":"请补充审核"}' \
--params '{"user_id_type":"user_id"}' \
--as user \
--yes
# 通过文件传入请求体,适合较长 comment
lark-cli approval tasks transfer \
--data @./transfer-body.json \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `transfer_user_id` | 是 | 被转交人的用户 ID需要和 `user_id_type` 保持一致 |
| `comment` | 否 | 审批意见或转交说明,例如 `转交给你处理``请继续审核该单据` |
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON用于声明 `transfer_user_id` 的 ID 类型 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;未显式指定时要特别确认 `transfer_user_id` 的真实类型 |
| `--as user` | 否 | 建议显式指定用户身份;审批转交通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson``table``csv` |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 典型前置步骤
先查到待办任务:
```bash
lark-cli approval tasks query --params '{"topic":"1"}' --as user
```
常用到的字段:
| 字段 | 说明 |
|------|------|
| `tasks[].instance_code` | 审批实例 Code执行 approve / reject / transfer / rollback 等操作时通常都需要 |
| `tasks[].task_id` | 审批任务 ID`instance_code` 配对使用 |
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;转交前建议先检查 |
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID再执行转交。
如需先确认表单、节点、审批流进度,可继续查看实例详情:
```bash
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
```
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行转交操作。
- **`transfer_user_id``user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 transfer 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行同意/拒绝等处理动作,转交前也应谨慎验证。
- **`comment` 建议写明转交原因**:例如 `你更熟悉该项目,请继续处理``转交给预算 owner 审核`,方便接收人理解上下文。
- **先 `--dry-run` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。

View File

@@ -45,13 +45,15 @@ lark-cli calendar +agenda --as user
| 场景 | 前置要求 |
|------|----------|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 编辑已有日程 | 先定位目标日程 `event_id` |
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程 `event_id`
- **日程实例Instance**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外
- **重复性日程例外Exception**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
@@ -71,6 +73,7 @@ lark-cli calendar +agenda --as user
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` |
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
## 任务类型分流

View File

@@ -0,0 +1,90 @@
# 重复性日程操作规范
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
## 关键概念
- **event_id 结构**`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime``0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性标题、时间、rrule、描述等都挂在本体上。
- **例外Exception**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}``originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
## 前置步骤(所有范围通用)
1. 通过 `+agenda``+search-event` 定位重复性日程,获取原重复性日程的 `event_id`
2. 通过 `events instance_view``+agenda` 列出实例,识别哪些是例外(`event_id``originalTime > 0` 的即为例外)。
3. 确认用户的操作范围。
## 编辑全部(更新时间)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
## 编辑全部(更新非时间字段)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
## 删除全部
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
## 编辑此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
## 删除此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
> 与「编辑此次及后续」的区别:不需要步骤 3创建新的重复性日程因为目标是删除后续而非替换。
## 仅此次
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`
## 用户意图映射
| 用户表达 | 操作范围 |
|----------|----------|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
| 「就改这一次」「只删这一次」 | 仅此次 |
| 未明确范围 | **必须询问用户** |
## 注意事项
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
## 参考
- [lark-calendar](../SKILL.md) — 日历全部命令
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -43,7 +43,7 @@ lark-cli calendar +update \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
@@ -65,7 +65,7 @@ lark-cli calendar +update \
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程的某一次实例时,必须先通过 `+agenda``+search-event` 或实例视图定位该实例的 `event_id`
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -1,11 +1,11 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
---
# docs
@@ -45,7 +45,6 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理

View File

@@ -1,113 +0,0 @@
# 飞书思维笔记Mindnote
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`
> [!IMPORTANT]
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 命令
```bash
# 先看命令帮助
lark-cli mindnotes nodes list --help
lark-cli mindnotes nodes create --help
# 读取节点列表
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
# 创建子节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
# 更新已有节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
```
## 参数
### `mindnotes nodes list`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
返回重点:`data.nodes` 中常见字段有 `node_id``parent_id``texts``notes``images``finish``highlight`
### `mindnotes nodes create`
命令参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
| `--data` | 是 | JSON 请求体 |
请求体字段:
| 字段 | 必填 | 说明 |
|------|------|------|
| `client_token` | 否 | 幂等 token建议写操作传入推荐使用时间戳或 UUID |
| `nodes` | 是 | 待创建或更新的节点数组 |
| `nodes[].node_id` | 否 | 节点 ID传入已有 `node_id` 时表示更新对应节点 |
| `nodes[].parent_id` | 否 | 父节点 ID创建子节点时传入 |
| `nodes[].texts` | 否 | 节点正文富文本数组 |
| `nodes[].notes` | 否 | 节点备注富文本数组 |
| `nodes[].images` | 否 | 节点图片列表 |
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
| `nodes[].finish` | 否 | 节点完成状态 |
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
```json
[{"element_type":"text","text":{"content":"节点内容"}}]
```
### 节点图片(`nodes[].images`
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
```bash
# 先上传图片,拿到 token
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
# 再把 token 写进节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
```
参数说明:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径 |
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
| `--parent-node` | 是 | 传 Mindnote 的 token |
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
## 推荐工作流
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先通过 token 类别判断。
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID避免重试时重复创建或重复更新。
> [!CAUTION]
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -59,8 +59,6 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m
### Card Messages (Interactive)
**Before sending or replying with any `interactive` card (`+messages-send` / `+messages-reply`), you MUST read [`references/card/lark-im-card-create.md`](references/card/lark-im-card-create.md) and follow its workflow.** The card JSON passed to `--msg-type interactive --content` must be the output of that workflow — never hand-write or copy a card payload.
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
`interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md).
@@ -104,7 +102,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
@@ -142,8 +139,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
### chat.members
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
### chat.user_setting
@@ -214,10 +213,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `chats.get` | `im:chat:read` |
| `chats.link` | `im:chat:read` |
| `chats.update` | `im:chat:update` |
| `chat.members.bots` | `im:chat.members:read` |
| `chat.members.create` | `im:chat.members:write_only` |
| `chat.members.delete` | `im:chat.members:write_only` |
| `chat.members.get` | `im:chat.members:read` |
| `+chat-members-list` | `im:chat.members:read` |
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
| `chat.managers.add_managers` | `im:chat.managers:write_only` |

View File

@@ -1,107 +0,0 @@
# 卡片 2.0 组件大纲
Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声明。先在下表按用途选组件,再点明细看字段:有明细文件的点 `components/<tag>.md`(完整字段+示例+易错点),低频组件点链接看官方文档。
## 根结构
顶层固定四字段,先搭骨架再往 `body.elements` 填组件。以下为**推荐完整骨架**(含 type scale、light/dark color token、header 三件套):
```json
{
"schema": "2.0",
"config": {
"update_multi": true,
"width_mode": "default",
"style": {
"text_size": {
"title": { "default": "heading-2", "pc": "heading-2", "mobile": "heading-3" },
"body": { "default": "normal", "pc": "normal", "mobile": "normal" },
"caption": { "default": "notation", "pc": "notation", "mobile": "notation" }
},
"color": {
"cus-primary": { "light_mode": "rgba(30,120,255,1)", "dark_mode": "rgba(80,150,255,1)" },
"cus-primary-bg": { "light_mode": "rgba(30,120,255,0.08)", "dark_mode": "rgba(80,150,255,0.12)" },
"cus-muted": { "light_mode": "rgba(100,106,115,1)", "dark_mode": "rgba(150,155,163,1)" }
}
}
},
"header": {
"title": { "tag": "plain_text", "content": "卡片标题" },
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
]
},
"body": { "direction": "vertical", "padding": "12px 12px 20px 12px", "elements": [] }
}
```
> **按需裁剪**`subtitle` / `text_tag_list` / color token 按实际诉求取舍,不强制全用。组件里用 `"text_size": "title"` / `"caption"` 引用 token用 `"font_color": "cus-muted"` 引用颜色 token主色系变化时只需改 config 里的 RGBA全卡自动跟随。
- `schema` 必须显式为 `"2.0"`,否则按 1.0 渲染。`header` 详见 `components/header.md`
- **元素通用字段**(所有 `elements[]` 组件):`tag`(必填) · `element_id`(卡内唯一字母开头、≤20 字符) · `margin`(外边距 [-99,99]px)。
- `card_link`(整卡跳转):`{url, pc_url, ios_url, android_url}`,至少填 `url`;某端禁跳设 `lark://msgcard/unsupported_action`
- 硬限制:单卡 ≤ **200** 元素;需客户端 **≥ 7.20**(旧版仅显示 header
- 颜色 / 图标枚举见 `resource/colors.md` · `resource/icons.md`
**config**(全局行为,可整体省略):
| 字段 | 默认 | 说明 |
|---|---|---|
| `update_multi` | true | 共享卡片v2 仅支持 true |
| `width_mode` | default | `default`(≤600px) / `compact`(400px) / `fill`(撑满) |
| `enable_forward` | true | 是否允许转发 |
| `summary` | — | 会话列表预览:`{content, i18n_content:{zh_cn,en_us,…}}` |
| `streaming_mode` | false | 流式更新模式(配 `streaming_config` |
| `style.text_size` | — | 自定义字号 token格式 `{"<名称>":{default,pc,mobile}}`;名称可自定义(如 `title`/`caption`),组件 `text_size` 引用该名称 |
| `style.color` | — | 自定义颜色 token格式 `{"<名称>":{light_mode,dark_mode}}`RGBA名称可自定义`cus-primary`),组件 `font_color`/`background_style` 等字段引用 |
> 多语言:`config.locales` 限定生效语种、`use_custom_translation` 优先用自带 i18n。
**body 布局字段**(均 v2 新增):`direction`(vertical/horizontal) · `padding`([0,99]px) · `horizontal_spacing`/`vertical_spacing`(`small`4/`medium`8/`large`12/`extra_large`16 或 px) · `horizontal_align`/`vertical_align`
---
## 容器类(布局 / 组织交互)
| 组件 | 用途 |
|---|---|
| [column_set](components/column_set.md) | 横向分栏,多列图文对齐(数据表、字段对、列表) |
| [collapsible_panel](components/collapsible_panel.md) | 折叠面板,收纳备注/长文本等次要信息 |
| [form](components/form.md) | 表单容器,批量录入表单项后一次提交 |
| [interactive_container](components/interactive_container.md) | 整块可点击区域,可统一定义样式与交互 |
| [循环容器](components/recycling_container.md) | 批量渲染同版式不同数据(仅搭建工具) |
## 展示类(无交互)
| 组件 | 用途 |
|---|---|
| [header](components/header.md) | 卡片标题区:主/副标题、后缀标签、主题色 |
| [div](components/div.md) | 普通文本,带前缀图标、字段对 |
| [markdown](components/markdown.md) | 富文本,最常用;@人、彩色、链接、列表、表格等 |
| [img](components/img.md) | 单图 |
| [img_combination](components/img_combination.md) | 多图拼排(双图/三图/宫格) |
| [person](components/person.md) | 单个人员头像/姓名 |
| [person_list](components/person_list.md) | 多个人员头像/姓名 |
| [chart](components/chart.md) | VChart 图表(折线/柱/饼/词云等) |
| [table](components/table.md) | 多列数据表(只能放根节点) |
| [hr](components/hr.md) | 分割线 |
## 交互类
| 组件 | 用途 |
|---|---|
| [button](components/button.md) | 按钮:回调 / 跳转 / 表单提交 |
| [input](components/input.md) | 文本输入框(多嵌在 form 内) |
| [overflow](components/overflow.md) | 折叠按钮组,收纳多个操作 |
| [select_static](components/select_static.md) | 下拉单选 |
| [multi_select_static](components/multi_select_static.md) | 下拉多选 |
| [select_person](components/select_person.md) | 人员单选 |
| [multi_select_person](components/multi_select_person.md) | 人员多选 |
| [date_picker](components/date_picker.md) | 日期选择器 |
| [picker_time](components/picker_time.md) | 时间选择器 |
| [picker_datetime](components/picker_datetime.md) | 日期时间选择器 |
| [select_img](components/select_img.md) | 图片选择(单/多选) |
| [checker](components/checker.md) | 勾选器,任务勾选回调 |

View File

@@ -1,63 +0,0 @@
# 按钮 `button`
交互按钮,支持跳转 / 回调 / 表单提交三类行为。**Card 2.0**。
## 最小示例
```json
{
"tag": "button",
"text": { "tag": "plain_text", "content": "确定" },
"type": "primary",
"behaviors": [{ "type": "callback", "value": { "action": "ok" } }]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `button` |
| `text` | 否 | Object | / | `{tag:"plain_text", content}`≤100 字符 |
| `type` | 否 | String | default | 见下方 type 枚举 |
| `size` | 否 | String | medium | `tiny` / `small` / `medium` / `large` |
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
| `behaviors` | 是* | Array | / | 交互行为,见下;表单内按钮不用 behaviors 而用 `form_action_type` |
| `icon` | 否 | Object | / | 前缀图标(同 `div.icon` |
| `hover_tips` | 否 | Object | / | PC 端悬浮提示plain_text |
| `disabled` | 否 | Boolean | false | 是否禁用 |
| `disabled_tips` | 否 | Object | / | 禁用后悬浮提示plain_text |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_texttitle 必填) |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
**type 枚举**`default`(黑字描边) / `primary`(蓝字描边) / `danger`(红字描边) / `text` / `primary_text` / `danger_text`(无边框) / `primary_filled`(蓝底白字) / `danger_filled`(红底白字) / `laser`(镭射)。
## 按钮主次(强制)
- 全卡仅 1 个按钮 → `type: "primary_filled"`,并 `width: "fill"` 撑满成强焦点。
- 多个并列按钮 → 第一个(主操作)`primary_filled`,其余一律 `default`,形成「一主多次」层级。
- 删除 / 拒绝等危险操作用 `danger` 系(`danger``danger_filled`)。
## behaviors交互行为
```json
// 1. 服务端回调
{ "type": "callback", "value": { "key": "v" } }
// 2. 跳转链接(可与 callback 同数组共存)
{ "type": "open_url", "default_url": "https://x", "pc_url": "", "ios_url": "", "android_url": "" }
```
表单容器内的按钮 **不用 behaviors**,改用根字段:
| 字段 | 必填 | 说明 |
|---|---|---|
| `name` | 是 | 表单内唯一标识 |
| `form_action_type` | 是 | `submit`(提交表单)/ `reset`(重置) |
## 嵌套 / 易错点
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
- 2.0 已废弃 `action` 交互模块,按钮直接放 `elements`,用间距控制排列。
- 旧式 `url`/`value` 顶层字段是 1.0 写法2.0 一律用 `behaviors`
- 点击触发 `card.action.trigger`,回传 `action.tag="button"` + `action.value`(即 callback 的 value

View File

@@ -1,57 +0,0 @@
# 图表 `chart`
基于 VChart 的可视化图表(折线/柱/饼/词云等)。**Card 2.0**。
## 最小示例
```json
{
"tag": "chart",
"chart_spec": {
"type": "line",
"title": { "text": "趋势" },
"data": { "values": [
{ "time": "周一", "value": 8 },
{ "time": "周二", "value": 14 }
] },
"xField": "time",
"yField": "value"
}
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `chart` |
| `chart_spec` | 是 | Object | / | VChart 图表定义,见下 |
| `aspect_ratio` | 否 | String | 16:9(PC)/1:1(移动) | `1:1` / `2:1` / `4:3` / `16:9` |
| `color_theme` | 否 | String | brand | `brand` / `rainbow` / `complementary` / `converse` / `primary`chart_spec 里声明了样式则此项无效 |
| `height` | 否 | String | auto | `auto`(按宽高比) 或 `[1,999]px`(设固定高则 aspect_ratio 失效) |
| `preview` | 否 | Boolean | true | 是否可独立窗口/全屏查看 |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## chart_spec 常用类型
`chart_spec` 是标准 VChart spec。核心字段`type``data.values`(数据数组)、`xField`/`yField`(轴字段)、`seriesField`(分组)、`title.text``legends`
| 图表 | type | 关键字段 |
|---|---|---|
| 折线 | `line` | `xField`, `yField` |
| 面积 | `area` | `xField`, `yField` |
| 柱状 | `bar` | `xField`, `yField`,分组加 `seriesField` |
| 条形(横向) | `bar` | `direction:"horizontal"``xField`=值,`yField`=类别 |
| 饼/环 | `pie` | `valueField`, `categoryField`,环图加 `innerRadius` |
| 散点 | `scatter` | `xField`, `yField` |
| 词云 | `wordCloud` | `nameField`, `valueField` |
完整属性参考 [VChart 官方文档](https://www.visactor.io/vchart/option/barChart)。
## 易错点
- 不支持 JavaScript 语法,`chart_spec` 必须是纯 JSON。
- 单卡建议 ≤5 个图表。
- 移动端不支持部分 VChart 属性(纹理 texture、conical 渐变、grid 词云布局等),用了会在移动端加载失败。
- 平台默认给 chart_spec 追加 media query 自适应;要自控可设 `"media": []`

View File

@@ -1,38 +0,0 @@
# 勾选器 `checker`
任务勾选场景的交互组件,支持配置回调响应。仅支持手写 JSON搭建工具不支持构建。**Card 2.0**。
## 最小示例
```json
{
"tag": "checker",
"name": "check_1",
"checked": false,
"text": { "tag": "plain_text", "content": "完成新品上市计划报告" },
"behaviors": [{ "type": "callback", "value": { "key": "todo1" } }]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `checker` |
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
| `checked` | 否 | Boolean | false | 初始勾选状态 |
| `text` | 否 | Object | / | `{tag:"plain_text"\|"lark_md", content, text_size?, text_color?, text_align?}`text_color 见 `../resource/colors.md` |
| `overall_checkable` | 否 | Boolean | true | 悬浮时整体是否有阴影效果 |
| `button_area` | 否 | Object | / | `{pc_display_rule:"always"|"on_hover", buttons:[<=3 个 button]}` |
| `checked_style` | 否 | Object | / | `{show_strikethrough, opacity}`,勾选后的内容样式 |
| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用及禁用提示 |
| `hover_tips` | 否 | Object | 空 | 悬浮提示;与 `disabled_tips` 同配时后者生效 |
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]`**未配置时仅本地勾选生效,不触发回调** |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
| `padding`/`margin` | 否 | String | 0 | [-99,99]px |
## 嵌套 / 易错点
- 可嵌套在 form / 交互容器 / column_set / collapsible_panel 内。
- 不配置 `behaviors` 时勾选仅前端本地生效,不会触发服务端回调——需要业务侧感知必须显式配置。
- 回调:`action.tag="checker"` + `action.checked`布尔值form 内则读 `form_value[name]`

View File

@@ -1,46 +0,0 @@
# 折叠面板 `collapsible_panel`
折叠次要内容(备注、长文本),点标题展开/收起。**Card 2.0**。
## 最小示例
```json
{
"tag": "collapsible_panel",
"expanded": false,
"header": { "title": { "tag": "plain_text", "content": "面板标题" } },
"elements": [{ "tag": "markdown", "content": "折叠的内容" }]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `collapsible_panel` |
| `header` | 是 | Object | / | 标题区,见下 |
| `elements` | 否 | Array | / | 面板内组件;**不能放 `form`** |
| `expanded` | 否 | Boolean | false | 是否默认展开 |
| `background_color` | 否 | String | 透明 | 面板背景,颜色枚举(见 `../resource/colors.md` |
| `border` | 否 | Object | / | `{ color, corner_radius }` |
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
| `vertical_spacing`/`horizontal_spacing` | 否 | String | 8px | 间距枚举或 [0,99]px |
| `padding` | 否 | String | 0 | 内边距 [0,99]px |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
**header 字段**
| 字段 | 必填 | 说明 |
|---|---|---|
| `title` | 否 | `{tag:"plain_text"\|"markdown", content}` |
| `background_color` | 否 | 标题区背景,颜色枚举 |
| `width` | 否 | `fill` / `auto` / `auto_when_fold`(收起时自适应) |
| `vertical_align` | 否 | `top`/`center`/`bottom` |
| `icon` | 否 | 图标 `{tag, token, color, size}`(同 `div.icon`,多 `size` |
| `icon_position` | 否 | `left` / `right` / `follow_text` |
| `icon_expanded_angle` | 否 | 展开时图标旋转角:`-180`/`-90`/`90`/`180` |
## 嵌套 / 易错点
- 内部不支持 `form`;容器最多嵌套 5 层。
- 仅支持写 JSON搭建工具不支持。

View File

@@ -1,53 +0,0 @@
# 分栏 `column_set` + `column`
横向多列布局容器。`column_set` 装若干 `column`,每个 `column` 内再放组件。**Card 2.0**。
## 最小示例
```json
{
"tag": "column_set",
"flex_mode": "none",
"columns": [
{ "tag": "column", "width": "weighted", "weight": 1,
"elements": [{ "tag": "markdown", "content": "左列" }] },
{ "tag": "column", "width": "weighted", "weight": 1,
"elements": [{ "tag": "markdown", "content": "右列" }] }
]
}
```
## column_set 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `column_set` |
| `columns` | 是 | column[] | / | 列数组,子节点只能是 `column` |
| `flex_mode` | 否 | String | none | 窄屏自适应:`none`(按比例压缩) / `stretch`(变上下堆叠) / `flow`(自动换行) / `bisect`(两等分) / `trisect`(三等分) |
| `horizontal_spacing` | 否 | String | 8px | `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` |
| `horizontal_align` | 否 | String | left | `left` / `center` / `right` |
| `background_style` | 否 | String | default | `default` 或颜色枚举/RGBA`../resource/colors.md`);嵌套时上层覆盖下层 |
| `action` | 否 | Object | / | 整块点击跳转 `{ multi_url:{url,pc_url,ios_url,android_url} }` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
## column 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `column` |
| `elements` | 否 | Element[] | / | 列内组件;**不能放 `form``table`**,可放 `column_set` |
| `width` | 否 | String | auto | 仅 `flex_mode:none` 生效:`auto` / `weighted`(配 weight) / `[16,600]px` |
| `weight` | 否 | Number | 1 | `width:weighted` 时的宽度占比1~5 整数 |
| `vertical_align` | 否 | String | top | `top` / `center` / `bottom` |
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px | 同上间距枚举或 `[0,99]px` |
| `padding` | 否 | String | 0 | 内边距 [0,99]px |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `background_style` | 否 | String | default | 同上 |
| `action` | 否 | Object | / | 点击列跳转,同 column_set.action |
## 嵌套 / 易错点
- **column_set 的直接子节点只能是 `column`**;不能 `column_set → column_set`。二级分栏要走 `column_set → column → column_set`
- column 内可放除 `form` / `table` 外的所有组件。
- 最多嵌套 5 层,过深会压缩展示空间。

View File

@@ -1,34 +0,0 @@
# 日期选择器 `date_picker`
提供日期选项的交互组件,默认拥有交互能力(无需显式 `behaviors` 也会回调)。**Card 2.0**。
## 最小示例
```json
{
"tag": "date_picker",
"placeholder": { "tag": "plain_text", "content": "请选择" },
"initial_date": "2024-01-01"
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `date_picker` |
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
| `required` | 否 | Boolean | false | 是否必选form 内生效) |
| `initial_date` | 否 | String | / | 初始值,格式 `yyyy-MM-dd`,会覆盖 `placeholder` |
| `placeholder` | 否 | Object | / | 占位文本plain_text未设 `initial_date` 时必填 |
| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` |
| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+ |
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
| `margin` | 否 | String | 0 | [-99,99]px |
## 嵌套 / 易错点
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。
- 提醒用户注意时区语境(如预定海外酒店用酒店所在地时区);服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。
- 回调:`action.tag="date_picker"` + `action.option`(日期字符串,如 `"2025-06-10 +0800"`+ `action.timezone`form 内则读 `form_value[name]`

View File

@@ -1,36 +0,0 @@
# 普通文本 `div`
带样式的文本块,支持前缀图标和 label-value 字段对。**Card 2.0**。富文本用 `markdown` 组件。
## 最小示例
```json
{
"tag": "div",
"text": { "tag": "plain_text", "content": "示例文本" }
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `div` |
| `text` | 否 | Object | / | 文本对象,见下 |
| `text.tag` | 是 | String | plain_text | `plain_text``lark_md`(部分 Markdown语法见 `markdown.md` |
| `text.content` | 是 | String | / | 文本内容 |
| `text.text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 不同字号 |
| `text.text_color` | 否 | String | default | 颜色枚举(见 `../resource/colors.md`),仅 `plain_text` 生效 |
| `text.text_align` | 否 | String | left | `left` / `center` / `right` |
| `text.lines` | 否 | Int | / | 最大显示行数,超出 `...` 省略 |
| `icon` | 否 | Object | / | 前缀图标,见下 |
| `icon.tag` | 否 | String | / | `standard_icon`(用 `token`+`color`token 见 `../resource/icons.md`)或 `custom_icon`(用 `img_key` |
| `width` | 否 | String | fill | `fill` / `auto` / `[16,999]px` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符;流式更新时 `text.element_id` 指定文本 |
> `fields` 字段(多列 label-value数组每项 `{ is_short, text:{tag,content} }``is_short:true` 可并排。
## 易错点
- `text_color` 只在 `text.tag``plain_text` 时生效;`lark_md` 用内联 `<font color=red>` 着色。

View File

@@ -1,51 +0,0 @@
# 表单容器 `form`
批量录入表单项后一次提交:用户在前端填写多个表单项,点击提交按钮后将所有值打包一次性回调到服务端。**Card 2.0**。
## 最小示例
```json
{
"tag": "form",
"name": "form_1",
"elements": [
{ "tag": "input", "name": "reason", "required": true },
{
"tag": "button",
"text": { "tag": "plain_text", "content": "提交" },
"type": "primary",
"form_action_type": "submit",
"name": "Button_submit"
}
]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `form` |
| `name` | 是 | String | / | 表单容器唯一标识,卡片内全局唯一,用于识别提交数据归属 |
| `elements` | 是 | Element[] | [] | 子节点,支持除 `table``form` 外的所有组件 |
| `direction` | 否 | String | vertical | `vertical` / `horizontal` |
| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px/12px | 间距枚举 `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` |
| `horizontal_align` | 否 | String | left | `left`/`center`/`right` |
| `vertical_align` | 否 | String | top | `top`/`center`/`bottom` |
| `padding`/`margin` | 否 | String | 0 | [-99,99]px支持单值/双值/四值写法 |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
### 子组件内嵌字段(交互组件嵌在 form 内时生效)
| 字段 | 必填 | 说明 |
|---|---|---|
| `name` | 是 | 表单内组件唯一标识,卡片全局唯一,否则提交失败 |
| `required` | 否 | 是否必填;为 true 且未填时点提交会本地拦截,不发起回调 |
| `form_action_type` | 是(按钮) | `submit`(提交)/ `reset`(重置初始值);表单内按钮**不用** `behaviors` |
## 嵌套 / 易错点
- `form` 不支持嵌套 `table``form`;且 `form` 本身只能放卡片根节点下,不能被其他组件嵌套。
- form 内所有交互组件的 `name` 必须填且全局唯一,否则提交失败。
- 表单内必须包含一个 `form_action_type: submit` 的按钮。
- 回调来源:`card.action.trigger``action.tag="button"` + `action.form_value`(按组件 `name` 映射各字段值)。

View File

@@ -1,34 +0,0 @@
# 标题 `header`
卡片顶部标题区(主/副标题、后缀标签、图标、主题色)。**Card 2.0**。挂在卡片根的 `header` 键下,不在 `body.elements` 内,单卡仅一个。
## 最小示例
```json
{
"header": {
"title": { "tag": "plain_text", "content": "卡片标题" },
"template": "blue"
}
}
```
## 字段
| 字段 | 必填 | 类型 | 说明 |
|---|---|---|---|
| `title` | 是 | Object | 主标题,`{tag:"plain_text"\|"lark_md", content}`,最多 4 行 |
| `subtitle` | 否 | Object | 副标题,同 title最多 1 行;只配副标题会按主标题展示 |
| `template` | 否 | String | 主题色枚举,见下;默认 `default` |
| `text_tag_list` | 否 | Array | 后缀标签,最多 3 个,每项 `{tag:"text_tag", text:{tag:"plain_text",content}, color}` |
| `i18n_text_tag_list` | 否 | Object | 多语言后缀标签;与 `text_tag_list` 二选一,同配以多语言为准 |
| `icon` | 否 | Object | 前缀图标(同 `div.icon` |
| `padding` | 否 | String | 内边距,默认 12px[0,99]px |
**template 枚举**13 色):`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey` / `default`
**标签 color 枚举**`neutral`/`blue`/`turquoise`/`lime`/`orange`/`violet`/`indigo`/`wathet`/`green`/`yellow`/`red`/`purple`/`carmine`。深浅档位及 RGBA 见 `../resource/colors.md`
## 选色建议
按场景选 template 颜色见 `../lark-im-card-style.md` 意图表。常见语义green=成功/完成orange=警告red=错误/危险grey=失效/归档blue=通用信息。

View File

@@ -1,17 +0,0 @@
# 分割线 `hr`
分隔卡片内容的水平线。**Card 2.0**1.0 同名 `hr`)。
## 最小示例
```json
{ "tag": "hr" }
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `hr` |
| `margin` | 否 | String | 0 | 外边距,范围 [-99,99]px`"8px 0"` |
| `element_id` | 否 | String | / | 组件唯一标识字母开头、≤20 字符 |

View File

@@ -1,34 +0,0 @@
# 图片 `img`
展示图片。需先调上传图片接口拿 `img_key`。**Card 2.0**。
## 最小示例
```json
{
"tag": "img",
"img_key": "img_v3_xxx",
"alt": { "tag": "plain_text", "content": "" }
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `img` |
| `img_key` | 是 | String | / | 图片 key上传图片接口获取 |
| `alt` | 是 | Object | / | hover 说明,`{tag:"plain_text", content:""}`,不需要传空 |
| `title` | 否 | Object | / | 图片标题plain_text 对象 |
| `scale_type` | 否 | String | crop_center | `crop_center` / `crop_top` / `fit_horizontal`(不裁剪) |
| `size` | 否 | String | / | 仅 `crop_*` 生效:`stretch`/`large`(160)/`medium`(80)/`small`(40)/`tiny`(16),或 `"100px 100px"` |
| `corner_radius` | 否 | String | / | 圆角,`[0,∞]px``[0,100]%` |
| `transparent` | 否 | Boolean | false | 是否透明底 |
| `preview` | 否 | Boolean | true | 点击是否放大;配 `card_link` 跳转时设 false |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## 易错点
- 通栏效果2.0 不再支持 `size: stretch_without_padding`,改用负 `margin`(如 `"4px -12px"`)。
- 上传规范≤10M、尺寸 ≤1500×3000px、高:宽 ≤16:9。

View File

@@ -1,30 +0,0 @@
# 多图混排 `img_combination`
多张图片按预设版式拼排。**Card 2.0**。
## 最小示例
```json
{
"tag": "img_combination",
"combination_mode": "double",
"img_list": [{ "img_key": "img_v3_a" }, { "img_key": "img_v3_b" }]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `img_combination` |
| `combination_mode` | 是 | String | / | `double`(≤2) / `triple`(≤3) / `bisect`(双列≤6) / `trisect`(三列≤9) |
| `img_list` | 是 | Array | / | 每项 `{ img_key }`,顺序即排列顺序 |
| `combination_transparent` | 否 | Boolean | false | 是否透明底 |
| `corner_radius` | 否 | String | / | 圆角,`[0,∞]px``[0,100]%` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## 易错点
- 图片数超过 mode 上限:只显示靠前的,其余丢弃;不足则留空白。
- 上传规范≤10M、≤1500×3000px、高:宽 ≤16:9。

View File

@@ -1,43 +0,0 @@
# 输入框 `input`
收集用户文本输入。常嵌在 `form` 内配合提交按钮使用。**Card 2.0**。
## 最小示例
```json
{
"tag": "input",
"name": "comment",
"placeholder": { "tag": "plain_text", "content": "请输入" },
"label": { "tag": "plain_text", "content": "备注:" }
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `input` |
| `name` | 否* | String | / | 唯一标识;**在 form 内必填且全局唯一**,用于识别提交数据 |
| `required` | 否 | Boolean | false | 是否必填(仅 form 内生效) |
| `placeholder` | 否 | Object | / | 占位文本plain_text≤100 字符 |
| `default_value` | 否 | String | / | 预填内容 |
| `label` | 否 | Object | / | 描述文本plain_text |
| `label_position` | 否 | String | top | `top` / `left`(窄屏自动转 top |
| `input_type` | 否 | String | text | `text` / `multiline_text`(多行,回调含 `\n`) / `password` |
| `rows` | 否 | Number | 5 | 多行时默认行数 |
| `auto_resize` | 否 | Boolean | false | 多行时高度自适应(仅 PC |
| `max_rows` | 否 | Number | / | `auto_resize` 时最大行数 |
| `max_length` | 否 | Number | 1000 | 最大字符数,[1,1000] |
| `show_icon` | 否 | Boolean | true | password 时是否显示前缀图标 |
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
| `disabled` | 否 | Boolean | false | 是否禁用(配 `disabled_tips` plain_text |
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
## 嵌套 / 易错点
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
- 在 form 内为**异步提交**:用户填完点提交按钮才一次性回调全部表单数据。
- 回调里 `action.tag="input"` + `action.input_value`用户输入值form 提交则值在 `form_value` 内。

View File

@@ -1,46 +0,0 @@
# 交互容器 `interactive_container`
整块可点击区域统一定义内嵌内容的样式和交互callback/open_url适合卡片内的列表项、可点击卡片块。**Card 2.0**。
## 最小示例
```json
{
"tag": "interactive_container",
"width": "fill",
"has_border": true,
"border_color": "grey",
"corner_radius": "8px",
"padding": "4px 12px 4px 12px",
"behaviors": [{ "type": "callback", "value": { "key": "value" } }],
"elements": [
{ "tag": "markdown", "content": "帮我生成一篇产品方案的框架" }
]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `interactive_container` |
| `elements` | 是 | Element[] | [] | 子节点,支持除 `form`/`table` 外的所有组件 |
| `behaviors` | 是 | Array | / | 点击整容器的交互:`callback`(回传)/ `open_url`(跳转),可同数组共存 |
| `width` | 否 | String | fill | `fill`/`auto`/`[16,999]px` |
| `height` | 否 | String | auto | `auto`/`[10,999]px` |
| `direction` | 否 | String | vertical | `vertical`/`horizontal` |
| `horizontal_align`/`vertical_align` | 否 | String | left/top | 对齐方式 |
| `background_style` | 否 | String | default | `default`/`laser`/颜色枚举/RGBA`../resource/colors.md` |
| `has_border` | 否 | Boolean | false | 是否展示 1px 边框 |
| `border_color` | 否 | String | grey | `has_border` 为 true 时生效 |
| `corner_radius` | 否 | String | 0px | `[0,∞]px``[0,100]%` |
| `padding`/`margin` | 否 | String | 4px,12px / 0px | 同间距写法 |
| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用整容器及禁用提示 |
| `hover_tips` | 否 | Object | 空 | PC 端悬浮提示 |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
## 嵌套 / 易错点
- 可嵌套除 `form`/`table` 外的所有组件,包括嵌套自身(列表项常见写法)。
- 若容器内有交互组件(如内部 `button`),优先响应该子组件的交互,容器级 `behaviors` 不会触发。
- 回调来源:`card.action.trigger``action.tag` 取决于内部触发的具体组件;容器本身被点击时 `action.value` 即容器 `behaviors.value`

View File

@@ -1,56 +0,0 @@
# 富文本 `markdown`
支持 Markdown + 部分 HTML 的富文本。最常用的内容组件。**Card 2.0**。
## 最小示例
```json
{
"tag": "markdown",
"content": "**标题**\n正文<font color='red'>红字</font>[链接](https://x)"
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `markdown` |
| `content` | 是 | String | / | Markdown 文本JSON 里用 `\n` 换行 |
| `text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 字号 |
| `text_align` | 否 | String | left | `left` / `center` / `right` |
| `icon` | 否 | Object | / | 前缀图标(同 `div.icon` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## 常用语法
| 效果 | 语法 |
|---|---|
| 粗 / 斜 / 删除线 | `**粗**``*斜*``~~删~~`(前后留空格更稳) |
| 换行 | JSON 内 `\n`;或 `<br>` |
| 文字链接 | `[文字](https://x)`(必须带 http/https |
| 带图标链接 | `<link icon='chat_outlined' …>文案</link>`icon token 见 `../resource/icons.md` |
| 彩色文本 | `<font color='red'>红字</font>`color 枚举见 `../resource/colors.md`;链接文本不可着色) |
| 标签 | `<text_tag color='blue'>标签</text_tag>`colorneutral/blue/turquoise/lime/orange/violet/indigo/wathet/green/yellow/red/purple/carmine |
| @ 人 | `<at id=ou_xxx></at>``<at id=all></at>``<at ids=id1,id2></at>` |
| @所有人 | `<at id=all></at>`(需群主开权限,否则发送失败) |
| 人员卡片 | `<person id='ou_xxx' show_name=true show_avatar=true style='normal'></person>` |
| 数字角标 | `<number_tag>1</number_tag>`0-99可加 background_color/font_color/url |
| 国际化时间 | `<local_datetime millisecond='' format_type='date_num'></local_datetime>` |
| 标题 | `# 一级` ~ `###### 六级`(大标题显丑,正文优先用加粗,见易错点) |
| 列表 | `- 项`(无序)/ `1. 项`有序4 空格一层缩进 |
| 引用 | `> 引用文字` |
| 行内/块代码 | `` `code` `` / ```` ```go ... ``` ````(可指定语言) |
| 分割线 | `<hr>` 或 `---`(需单独一行) |
| 图片 | `![hover文案](img_key)` |
| 表格 | 标准 MD 表格;除标题最多 5 行(超出分页),单组件 ≤4 表 |
| 飞书表情 | `:DONE:`、`:OK:` |
## 易错点
- **慎用大标题**`#` / `##` / `###` 一~三级标题字号过大、显丑,正文里一律用 `**加粗**` 替代来突出重点。**唯一例外**是「指标卡」里用 `##` 放大数值(见 `../lark-im-card-style.md` 视觉规范)。
- **少用 `markdown` 的 `margin`**:间距优先交给父容器的 `vertical_spacing` / `padding`,多数情况置 `0px`;仅精细缩进时设非零值(见 `../lark-im-card-style.md` 间距纪律)。
- 2.0 不再支持旧的 `[xx]($urlVal)` + `href` 差异化跳转语法,改用 `<link>`。
- 要展示 Markdown 特殊字符(`* ~ > < [ ] ( ) # : _` 等)须 HTML 转义,如 `<`→`&#60;`、`*`→`&#42;`。
- `content` 里的引号注意与 JSON 转义;属性值用单引号可减少冲突。

View File

@@ -1,40 +0,0 @@
# 人员选择-多选 `multi_select_person`
从候选人员中多选。**Card 2.0**。字段与 `select_person` 基本一致,差别在多选默认值。
## 最小示例
```json
{
"tag": "multi_select_person",
"name": "reviewers",
"placeholder": { "tag": "plain_text", "content": "请选择" },
"options": [
{ "value": "ou_xxx" },
{ "value": "ou_yyy" }
]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `multi_select_person` |
| `options` | 否 | Array | / | 候选人 `{value: open_id}`;为空或全无效时候选项为会话全体成员 |
| `selected_values` | 否 | String[] | / | 默认选中的 open_id 数组 |
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
| `required` | 否 | Boolean | false | 是否必选form 内生效) |
| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) |
| `placeholder` | 否 | Object | / | 占位文本plain_text |
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
| `disabled` | 否 | Boolean | false | 是否禁用 |
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
## 嵌套 / 易错点
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
- `options[].value` 只接受 **open_id**;默认选中用 `selected_values`(数组)。
- 回调返回选中的多个 open_id。

View File

@@ -1,40 +0,0 @@
# 下拉多选 `multi_select_static`
下拉菜单多选。**Card 2.0**。字段与 `select_static` 基本一致,差别在多选默认值。
## 最小示例
```json
{
"tag": "multi_select_static",
"name": "tags",
"placeholder": { "tag": "plain_text", "content": "请选择" },
"options": [
{ "text": { "tag": "plain_text", "content": "选项1" }, "value": "1" },
{ "text": { "tag": "plain_text", "content": "选项2" }, "value": "2" }
]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `multi_select_static` |
| `options` | 否 | Array | / | 选项 `{text:{plain_text}, value, icon?}``value` 不可重复 |
| `selected_values` | 否 | String[] | / | 默认选中的 value 数组 |
| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** |
| `required` | 否 | Boolean | false | 是否必选form 内生效) |
| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) |
| `placeholder` | 否 | Object | / | 占位文本plain_text |
| `width` | 否 | String | default | `default`(带框固定282px) / `fill` / `[100,∞)px` |
| `disabled` | 否 | Boolean | false | 是否禁用 |
| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
## 嵌套 / 易错点
- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。
- 选项 `value` 唯一;默认选中用 `selected_values`(数组)而非单选的 `initial_*`
- 回调返回选中的多个值。

View File

@@ -1,36 +0,0 @@
# 折叠按钮组 `overflow`
折叠多个选项按钮,点击展开。适用于操作较多的场景。**Card 2.0**。
## 最小示例
```json
{
"tag": "overflow",
"options": [
{ "text": { "tag": "plain_text", "content": "选项A" }, "value": "a" },
{ "text": { "tag": "plain_text", "content": "选项B" }, "value": "b" }
]
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `overflow` |
| `options` | 是 | Array | / | 选项按钮,见下 |
| `options[].text` | 否 | Object | / | `{tag:"plain_text", content}`≤100 字符 |
| `options[].value` | 否 | String | / | 点击回传值,用于区分点了哪个选项(回调 `action.option` |
| `options[].multi_url` | 否 | Object | / | 跳转链接 `{url, pc_url, ios_url, android_url}` |
| `behaviors` | 否 | Array | / | 额外回传:`[{type:"callback", value:{...}}]` |
| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_text |
| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## 嵌套 / 易错点
- 可嵌套在 form / collapsible_panel / 循环容器 / interactive_container / column_set 内。
- 多按钮时务必给每个 `options[].value`,否则回调无法区分点了哪个。
- 点击触发 `card.action.trigger`,回传 `action.tag = "overflow"` + `action.option`

View File

@@ -1,30 +0,0 @@
# 人员 `person`
展示单个用户的头像/姓名,点击可看名片。**Card 2.0**。
## 最小示例
```json
{
"tag": "person",
"user_id": "ou_xxx",
"show_name": true
}
```
## 字段
| 字段 | 必填 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| `tag` | 是 | String | / | 固定 `person` |
| `user_id` | 是 | String | / | 人员 ID支持 open_id / union_id / user_id |
| `size` | 否 | String | medium | `extra_small` / `small` / `medium` / `large` |
| `show_avatar` | 否 | Boolean | true | 是否显示头像 |
| `show_name` | 否 | Boolean | false | 是否显示姓名 |
| `style` | 否 | String | normal | `normal` / `capsule`(胶囊) |
| `margin` | 否 | String | 0 | 外边距 [-99,99]px |
| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 |
## 易错点
- 发卡应用需有访问用户 ID 的权限,否则人员信息无法展示。

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