mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
2 Commits
feat/lark-
...
feat/calen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d539841ff6 | ||
|
|
49fe19e68d |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,5 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
OKRPatch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_node:need_approver=true,approver_chosen_multi=false
|
||||
- hr_node:need_approver=false
|
||||
```
|
||||
@@ -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`。
|
||||
@@ -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://...
|
||||
```
|
||||
|
||||
@@ -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` 的最终结构。
|
||||
|
||||
@@ -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` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。
|
||||
@@ -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` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。
|
||||
@@ -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` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。
|
||||
@@ -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` 提升可读性。
|
||||
@@ -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` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。
|
||||
@@ -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` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。
|
||||
@@ -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` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。
|
||||
@@ -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` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。
|
||||
@@ -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),确认操作范围后执行 |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
|
||||
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal 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) — 认证和全局参数
|
||||
@@ -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 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
|
||||
@@ -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` 处理
|
||||
|
||||
@@ -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) — 认证和全局参数
|
||||
@@ -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` |
|
||||
|
||||
@@ -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) | 勾选器,任务勾选回调 |
|
||||
@@ -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_text,title 必填) |
|
||||
| `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)。
|
||||
@@ -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": []`。
|
||||
@@ -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]`。
|
||||
@@ -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,搭建工具不支持。
|
||||
@@ -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 层,过深会压缩展示空间。
|
||||
@@ -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]`。
|
||||
@@ -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>` 着色。
|
||||
@@ -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` 映射各字段值)。
|
||||
@@ -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=通用信息。
|
||||
@@ -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 字符 |
|
||||
@@ -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。
|
||||
@@ -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。
|
||||
@@ -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` 内。
|
||||
@@ -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`。
|
||||
@@ -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>`(color:neutral/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>` 或 `---`(需单独一行) |
|
||||
| 图片 | `` |
|
||||
| 表格 | 标准 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 转义,如 `<`→`<`、`*`→`*`。
|
||||
- `content` 里的引号注意与 JSON 转义;属性值用单引号可减少冲突。
|
||||
@@ -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。
|
||||
@@ -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_*`。
|
||||
- 回调返回选中的多个值。
|
||||
@@ -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`。
|
||||
@@ -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
Reference in New Issue
Block a user