Compare commits

..

8 Commits

Author SHA1 Message Date
caichengjie.viper
c313f8bd1a fix(slides): local XML precheck, 99991400 backoff
Lands three of the four agreed fixes from the 2026-06-08 slides write-path
telemetry analysis (the commercial quota-code registration is deferred to a
separate change):

1. Local XML well-formedness precheck (shortcuts/slides/)
   - checkXMLWellFormed: pure syntax validation via stdlib encoding/xml
     (same parser family as the backend, false-positive risk ~0);
     explicitly rejects <?xml ?> declarations; deliberately allows
     multiple top-level elements (legal in block_insert fragments)
   - wired into +create --slides (at Validate, so a bad slide no longer
     leaves a half-built deck) and +replace-slide --parts
     replacement/insertion; errors carry line numbers + escaping
     guidance, rejected locally with zero API calls

2. 99991400 rate-limit backoff (retryOnRateLimit)
   - the code was registered Retryable:true but no slides loop actually
     retried, so one frequency-window hit aborted the whole batch
   - up to 2 retries with 1s/2s backoff, announced on stderr,
     context-cancellable; wired into the +create slide POST loop and
     uploadSlidesMedia (+media-upload and the placeholder upload loop)
   - upload switched to UploadDriveMediaAllTyped (retry match requires
     typed errors; aligns with the slides typed migration)

3. lark-slides skill tag-whitelist ban (skills/lark-slides/)
   - quick-ref: never write tags outside the whitelist, name the six
     confirmed-rejected tags (audio/video/timeline/animation/trigger/
     header), substitution table, escaping rules
   - removed <?xml ?> declarations from all examples (contradicted
     backend behavior and the new precheck)

Tested with unit + httpmock integration tests, plus live verification
against the real feishu.cn API: all precheck negatives rejected locally,
no false positives on real create/replace, and 18 concurrent uploads hit
3 real 99991400 responses which all retried and succeeded (18/18).

CCM-Harness: set-lark-cli-dev-env,spec
2026-06-23 19:58:01 +08:00
jiangguozhou
824aa9edf8 docs: add lark-drive permission governance workflow (#1292)
Change-Id: Ib62bd439669fec3e9d5589d1fbe266d3aef964a8
2026-06-22 14:20:02 +08:00
zhanghuanxu
9d4ae94394 feat(slides):slide screenshot 2026-06-22 13:20:39 +08:00
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
60 changed files with 9711 additions and 1143 deletions

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
@@ -1189,6 +1212,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53

View File

@@ -285,18 +285,12 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
if err == nil {
t.Fatal("expected error for non-terminal without flags")
}
if !strings.Contains(err.Error(), "terminal") {
t.Errorf("expected error to mention terminal, got: %s", err.Error())
msg := err.Error()
if !strings.Contains(msg, "--new") {
t.Errorf("expected error to mention --new, got: %s", msg)
}
// Missing-terminal is a failed precondition (valid request, wrong runtime
// state), and the actionable guidance lives in the hint.
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeFailedPrecondition, p)
}
// Lock the two-step guidance contract: the hint must point at both flags.
if !strings.Contains(p.Hint, "--no-wait") || !strings.Contains(p.Hint, "--device-code") {
t.Errorf("hint should describe the two-step flow (--no-wait / --device-code), got: %s", p.Hint)
if !strings.Contains(msg, "terminal") {
t.Errorf("expected error to mention terminal, got: %s", msg)
}
}

View File

@@ -32,13 +32,6 @@ type ConfigInitOptions struct {
Brand string
New bool
// NoWait initiates a new-app creation and returns immediately with a
// device code (non-blocking step 1); DeviceCode completes a creation
// previously started with --no-wait (non-blocking step 2). They mirror
// `auth login`'s --no-wait / --device-code split.
NoWait bool
DeviceCode string
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
@@ -63,11 +56,9 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: prefer the non-blocking two-step flow. Run '--new --no-wait' to
get a device code and verification URL immediately (printed as JSON), send the
URL/QR to the user, then run '--device-code <code>' after they confirm to finish.
The plain '--new' still blocks until the user completes setup in the browser if
you need the old behavior.
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
@@ -90,8 +81,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "create a new app but return immediately with a device code; complete later with --device-code (non-blocking, for AI agents)")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "complete a new-app creation started with --no-wait, using its device code")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
@@ -143,7 +132,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin || o.NoWait || o.DeviceCode != ""
return o.New || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
@@ -319,22 +308,6 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
// Validate the non-blocking flags before touching stdin so a contradictory
// combination (e.g. --no-wait --app-secret-stdin) fails fast instead of
// blocking on a stdin read.
if opts.NoWait && opts.DeviceCode != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait and --device-code cannot be used together").WithParam("--device-code")
}
if (opts.NoWait || opts.DeviceCode != "") && (opts.AppID != "" || opts.AppSecretStdin) {
// Point remediation at whichever non-blocking flag the caller actually
// passed (mutual exclusion above guarantees at most one is set here).
conflictParam := "--no-wait"
if opts.DeviceCode != "" {
conflictParam = "--device-code"
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait/--device-code create a new app and cannot be combined with --app-id/--app-secret-stdin").WithParam(conflictParam)
}
// Read secret from stdin if --app-secret-stdin is set
if opts.AppSecretStdin {
scanner := bufio.NewScanner(f.IOStreams.In)
@@ -362,15 +335,6 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
// Non-blocking step 2: complete a creation started with --no-wait.
if opts.DeviceCode != "" {
return resumeAppRegistration(opts)
}
// Non-blocking step 1: initiate a new-app creation and return immediately.
if opts.NoWait {
return initiateNoWaitAppRegistration(opts, existing)
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -473,12 +437,9 @@ func configInitRun(opts *ConfigInitOptions) error {
return nil
}
// Non-terminal: the request is valid but the runtime state is wrong (no
// terminal for interactive mode) — a failed precondition, not a bad
// argument. Point the caller at the non-blocking two-step flow.
// Non-terminal: cannot run interactive mode, guide user to --new
if !f.IOStreams.IsTerminal {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "config init interactive mode requires a terminal").
WithHint("Create a new app non-interactively with the two-step flow: `lark-cli config init --new --no-wait` (prints device_code + verification_url, returns immediately), then `lark-cli config init --device-code <code>` after the user finishes in the browser. Or run `lark-cli config init --new` in a terminal.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
}
// Mode 5: Legacy interactive (readline fallback)

View File

@@ -182,11 +182,6 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
// Pass a lower-layer typed error (e.g. a network/transport error) through
// unchanged; only wrap genuinely-untyped failures as invalid_client.
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// newRegistrationHTTPClient builds the HTTP client used for app-registration
// traffic. It is a package var so tests can inject a stub transport.
var newRegistrationHTTPClient = func() *http.Client { return transport.NewHTTPClient(0) }
// initNoWaitHint is the agent-facing guidance embedded in the --no-wait JSON
// output, mirroring the two-step contract of `auth login --no-wait`.
const initNoWaitHint = "**Generate AND display the QR code:** call `lark-cli auth qrcode <verification_url>` and show it (PNG via --output; ASCII via --ascii only if the user asks). " +
"**You MUST include the QR image in your response** — generating the file alone is not enough. Output the URL first, then the QR image below it. " +
"**Treat verification_url as an opaque string** — do not URL-encode/decode it or add spaces/punctuation. " +
"**Hand control back:** make the QR/URL the final message of this turn; do NOT run --device-code in the same turn. Tell the user to come back and notify you after they finish creating the app in the browser. " +
"**After the user confirms:** YOU must finish by running lark-cli with the exact arguments in `resume_args`, passing each element as a separate literal argument (do not re-quote or shell-interpret them). It already carries the right flags. " +
"**Do NOT cache verification_url or device_code** — run `lark-cli config init --new --no-wait` fresh whenever a new app is needed."
// initiateNoWaitAppRegistration runs the non-blocking first step: request a
// device code, cache the resume context, print JSON, and return immediately
// without polling.
func initiateNoWaitAppRegistration(opts *ConfigInitOptions, existing *core.MultiAppConfig) error {
f := opts.Factory
brand := parseBrand(opts.Brand)
httpClient := newRegistrationHTTPClient()
authResp, err := larkauth.RequestAppRegistration(httpClient, brand, f.IOStreams.ErrOut)
if err != nil {
// Pass a lower-layer typed error (e.g. a network/transport error) through
// unchanged; only wrap genuinely-untyped failures as invalid_client.
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: string(brand),
ProfileName: opts.ProfileName,
Lang: opts.Lang,
LangExplicit: opts.langExplicit,
Interval: authResp.Interval,
ExpiresAt: time.Now().Unix() + int64(authResp.ExpiresIn),
ConfigDigest: computeConfigDigest(existing),
}
// The resume step (--device-code) fully depends on this cache to finish
// persisting the app — unlike auth login, which can re-derive its scope. So
// a cache-write failure is fatal: fail now rather than hand back a
// device_code the user can never complete.
if err := saveInitNoWaitRecord(authResp.DeviceCode, rec); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to persist the context needed by `config init --device-code`: %v", err).WithCause(err)
}
// Emit the resume step as an argv array rather than a shell string: the
// device_code is opaque and may contain spaces or metacharacters, and a
// single quoted string can't be both POSIX- and cmd.exe-safe. argv sidesteps
// quoting entirely — agents pass each element as a literal argument.
// --force-init must be carried along: guardAgentWorkspace runs in RunE
// before the cache is read, so resuming without it inside an agent workspace
// would be rejected. (Profile name is recovered from the cache.)
resumeArgs := []string{"lark-cli", "config", "init", "--device-code", authResp.DeviceCode}
if opts.ForceInit {
resumeArgs = append(resumeArgs, "--force-init")
}
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
data := map[string]interface{}{
"verification_url": verificationURL,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"resume_args": resumeArgs,
"hint": initNoWaitHint,
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
}
return nil
}
// resumeAppRegistration runs the non-blocking second step: poll with a device
// code from a previous --no-wait call, then persist the new app and probe it.
func resumeAppRegistration(opts *ConfigInitOptions) error {
f := opts.Factory
rec, err := loadInitNoWaitRecord(opts.DeviceCode)
if err != nil {
// The record exists but could not be read/parsed (permissions, disk,
// corruption). The resume step fully depends on this cache, so surface a
// storage error instead of the misleading "no pending creation"
// validation path — the user should fix local storage, not assume the
// device code is bad and throw away a still-valid creation attempt.
return errs.NewInternalError(errs.SubtypeStorage, "failed to read the cached resume context: %v", err).WithCause(err)
}
if rec == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"no pending app creation found for this device code; re-initiate with `lark-cli config init --new --no-wait`").
WithParam("--device-code")
}
// Expiry check against the cached absolute deadline (device codes are
// short-lived — the registration default is 300s).
remaining := rec.ExpiresAt - time.Now().Unix()
if remaining <= 0 {
_ = removeInitNoWaitRecord(opts.DeviceCode)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"device code expired; re-initiate with `lark-cli config init --new --no-wait`").
WithParam("--device-code")
}
// Drift guard (fast path): bail out before the long poll if the config
// already changed since initiation, so we don't waste minutes polling.
existing, err := loadConfigForDriftCheck()
if err != nil {
return err
}
if computeConfigDigest(existing) != rec.ConfigDigest {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"configuration changed since this app creation was started; re-initiate with `lark-cli config init --new --no-wait` to avoid overwriting it").
WithParam("--device-code")
}
interval := rec.Interval
if interval <= 0 {
interval = 5
}
httpClient := newRegistrationHTTPClient()
result, pollErr := pollAppRegistrationResume(opts.Ctx, httpClient, opts.DeviceCode, interval, int(remaining), f.IOStreams.ErrOut)
if pollErr != nil {
// Clear the cache only on terminal failures (denied / expired /
// timed-out). Keep it on cancellation or transient errors so the user
// can retry with the same device code while it is still valid.
if appRegShouldClearCache(pollErr) {
_ = removeInitNoWaitRecord(opts.DeviceCode)
}
// Pass an already-typed error through unchanged (e.g. the ConfigError
// for a missing client_id/secret) instead of downgrading it to
// authentication/unknown — matching runCreateAppFlow.
if _, ok := errs.ProblemOf(pollErr); ok {
return pollErr
}
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", pollErr).WithCause(pollErr)
}
// Re-check drift immediately before persisting. The poll above can block
// for minutes while the user finishes in the browser, and a concurrent
// process may have changed config.json in that window — saving the stale
// pre-poll snapshot would drop those edits. Reload and compare again.
existing, err = loadConfigForDriftCheck()
if err != nil {
return err
}
if computeConfigDigest(existing) != rec.ConfigDigest {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"configuration changed while the app was being created, so it was not saved (to avoid overwriting that change); re-run `lark-cli config init --new --no-wait`").
WithParam("--device-code")
}
// Determine the final brand from the response, falling back to the cached
// brand. The cached brand only seeds link generation + this fallback; the
// Lark-tenant re-poll inside pollAppRegistrationResume is what actually
// detects a Lark tenant.
finalBrand := parseBrand(rec.Brand)
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
}
secret, err := core.ForStorage(result.ClientID, core.PlainSecret(result.ClientSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(rec.ProfileName, existing, f, result.ClientID, secret, finalBrand, rec.Lang); err != nil {
// Preserve a typed error (e.g. the --name conflict ValidationError) via
// the shared helper instead of downgrading everything to storage —
// matching the blocking init paths.
return wrapSaveConfigError(err)
}
// Config persisted — only now is it safe to drop the resume cache. Clearing
// it only after a successful save means a failure in the drift re-check,
// ForStorage, or saveInitConfig above leaves the cache intact so the user
// can retry `--device-code` (the remote app already exists).
_ = removeInitNoWaitRecord(opts.DeviceCode)
if rec.LangExplicit && rec.Lang != "" {
msg := getInitMsg(opts.UILang)
fmt.Fprintln(f.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, rec.Lang))
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.ClientID, "appSecret": "****", "brand": finalBrand})
if err := runProbe(opts.Ctx, f, result.ClientID, result.ClientSecret, finalBrand); err != nil {
return err
}
return nil
}
// pollAppRegistrationResume polls the registration endpoint (feishu first, then
// the lark endpoint on the tenant_brand=lark special case) and returns the raw
// error so the caller can classify it for cache-cleanup decisions.
func pollAppRegistrationResume(ctx context.Context, httpClient *http.Client, deviceCode string, interval, expiresIn int, errOut io.Writer) (*larkauth.AppRegistrationResult, error) {
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, deviceCode, interval, expiresIn, errOut)
if err != nil {
return nil, err
}
// Lark tenant special case: if tenant_brand=lark and no client_secret,
// re-poll against the lark endpoint to obtain the secret.
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, deviceCode, interval, expiresIn, errOut)
if err != nil {
return nil, err
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
return result, nil
}
// appRegShouldClearCache reports whether the cached resume context should be
// discarded after a poll outcome. Success and terminal failures (user denied,
// device code expired, deadline elapsed) clear it; cancellation and transient
// errors keep it so the user can retry while the device code is still valid.
func appRegShouldClearCache(err error) bool {
if err == nil {
return true
}
return errors.Is(err, larkauth.ErrAppRegDenied) ||
errors.Is(err, larkauth.ErrAppRegExpired) ||
errors.Is(err, larkauth.ErrAppRegTimeout)
}
// loadConfigForDriftCheck loads the config for the drift comparison. A missing
// config (first-time setup) is fine — it yields a nil config and an empty
// digest. A genuine storage failure (permission denied, corruption) is surfaced
// as a typed storage error rather than being silently read as "config drift".
func loadConfigForDriftCheck() (*core.MultiAppConfig, error) {
existing, err := core.LoadMultiAppConfig()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errs.NewInternalError(errs.SubtypeStorage, "failed to load config for the drift check: %v", err).WithCause(err)
}
return existing, nil
}

View File

@@ -1,116 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// initNoWaitCacheVersion is the schema version of the cached init context.
// Bump it when the record shape changes so stale entries are ignored.
const initNoWaitCacheVersion = 1
// initNoWaitRecord is the context persisted by `config init --new --no-wait` so
// that the later `--device-code` step can complete the app creation. It must
// never hold a secret, verification URL, or full config — only what the resume
// step needs to finish persisting the new app.
type initNoWaitRecord struct {
Version int `json:"version"`
Brand string `json:"brand"`
ProfileName string `json:"profile_name"`
Lang string `json:"lang"`
LangExplicit bool `json:"lang_explicit"`
Interval int `json:"interval"`
ExpiresAt int64 `json:"expires_at"` // unix seconds; absolute device-code deadline
ConfigDigest string `json:"config_digest"`
}
// initNoWaitCacheDir returns the directory used to persist config init
// --no-wait context keyed by device_code.
func initNoWaitCacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache", "config_init_nowait")
}
// initNoWaitCachePath returns the cache file path for a given device_code.
func initNoWaitCachePath(deviceCode string) string {
return filepath.Join(initNoWaitCacheDir(), initNoWaitCacheKey(deviceCode)+".json")
}
// initNoWaitCacheKey derives a collision-free, filesystem-safe filename token
// from an opaque device_code. A sha256 hex digest avoids the collisions a
// character-replacement sanitizer would cause (e.g. "a/b" and "a:b" both
// mapping to "a_b").
func initNoWaitCacheKey(deviceCode string) string {
sum := sha256.Sum256([]byte(deviceCode))
return hex.EncodeToString(sum[:])
}
// saveInitNoWaitRecord persists the resume context for a device_code.
func saveInitNoWaitRecord(deviceCode string, rec initNoWaitRecord) error {
if err := vfs.MkdirAll(initNoWaitCacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(rec)
if err != nil {
return err
}
return validate.AtomicWrite(initNoWaitCachePath(deviceCode), data, 0600)
}
// loadInitNoWaitRecord loads the resume context for a device_code. It returns
// (nil, nil) when no cache entry exists.
func loadInitNoWaitRecord(deviceCode string) (*initNoWaitRecord, error) {
data, err := vfs.ReadFile(initNoWaitCachePath(deviceCode))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var rec initNoWaitRecord
if err := json.Unmarshal(data, &rec); err != nil {
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
return nil, err
}
if rec.Version != initNoWaitCacheVersion {
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
return nil, nil
}
return &rec, nil
}
// removeInitNoWaitRecord deletes the cache entry for a device_code.
func removeInitNoWaitRecord(deviceCode string) error {
err := vfs.Remove(initNoWaitCachePath(deviceCode))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// computeConfigDigest returns a stable digest of the existing config so the
// resume step can detect drift between initiation and completion. The digest
// is a hash of config.json content (app IDs, brands, users, secret references)
// — it contains no plaintext secret and is safe to cache. A nil config and an
// (unexpected) marshal error both map to the empty digest.
func computeConfigDigest(existing *core.MultiAppConfig) string {
if existing == nil {
return ""
}
data, err := json.Marshal(existing)
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

View File

@@ -1,521 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// roundTripFunc adapts a function to an http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// TestNoWait_InitiateThenResume_EndToEnd drives the full two-step flow against a
// real local HTTP server: initiate writes the on-disk cache, then a SEPARATE
// resume call polls the same server, succeeds, and persists the new app. Only
// the device_code + the cache bridge the two invocations — exactly as the two
// CLI commands would. (A black-box binary E2E of the success path is impossible
// without a human: endpoints are hardcoded HTTPS and the real device flow needs
// a browser scan, so this in-process run through httptest is the highest-fidelity
// autonomous end-to-end.)
func TestNoWait_InitiateThenResume_EndToEnd(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
switch r.FormValue("action") {
case "begin":
_, _ = w.Write([]byte(`{"device_code":"E2E-DEVICE-CODE","user_code":"E2E-UC","verification_uri":"https://example.test/verify","expires_in":600,"interval":1}`))
case "poll":
_, _ = w.Write([]byte(`{"client_id":"cli_e2e","client_secret":"sec_e2e","user_info":{"tenant_brand":"feishu","open_id":"ou_e2e"}}`))
default:
http.Error(w, "unexpected action "+r.FormValue("action"), http.StatusBadRequest)
}
}))
defer ts.Close()
tsURL, _ := url.Parse(ts.URL)
// Redirect the registration client to the local test server.
orig := newRegistrationHTTPClient
newRegistrationHTTPClient = func() *http.Client {
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
r.URL.Scheme, r.URL.Host = tsURL.Scheme, tsURL.Host
return http.DefaultTransport.RoundTrip(r)
})}
}
t.Cleanup(func() { newRegistrationHTTPClient = orig })
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
// Step 1 — initiate: should print device_code and write the resume cache.
initOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true}
if err := initiateNoWaitAppRegistration(initOpts, nil); err != nil {
t.Fatalf("initiate: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("initiate stdout not JSON: %v; raw=%s", err, stdout.String())
}
if out["device_code"] != "E2E-DEVICE-CODE" {
t.Fatalf("device_code = %v, want E2E-DEVICE-CODE", out["device_code"])
}
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec == nil {
t.Fatal("initiate did not write the resume cache")
}
// Step 2 — resume (separate invocation; bridged only by device_code + cache).
stdout.Reset()
resumeOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "E2E-DEVICE-CODE"}
if err := resumeAppRegistration(resumeOpts); err != nil {
t.Fatalf("resume: %v", err)
}
// The new app must be persisted to config...
cfg, err := core.LoadMultiAppConfig()
if err != nil || cfg == nil {
t.Fatalf("config not persisted: %v", err)
}
if app := cfg.CurrentAppConfig(""); app == nil || app.AppId != "cli_e2e" {
t.Fatalf("persisted app = %+v, want AppId cli_e2e", app)
}
// ...the cache cleared after the successful save...
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec != nil {
t.Error("resume should clear the cache after a successful save")
}
// ...and the success JSON emitted.
if !strings.Contains(stdout.String(), "cli_e2e") {
t.Errorf("resume stdout missing appId: %s", stdout.String())
}
}
// stubRT returns a single canned HTTP response for every request.
type stubRT struct {
status int
body string
}
func (s stubRT) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: s.status, Body: io.NopCloser(strings.NewReader(s.body)), Header: make(http.Header)}, nil
}
// seqRT returns successive canned responses (last one repeats), for flows that
// poll more than once (e.g. the Lark-tenant re-poll).
type seqRT struct {
bodies []string
i int
}
func (s *seqRT) RoundTrip(*http.Request) (*http.Response, error) {
idx := s.i
if idx >= len(s.bodies) {
idx = len(s.bodies) - 1
}
s.i++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(s.bodies[idx])), Header: make(http.Header)}, nil
}
// withStubRegistrationClient swaps the registration HTTP client for the test.
func withStubRegistrationClient(t *testing.T, rt http.RoundTripper) {
t.Helper()
orig := newRegistrationHTTPClient
newRegistrationHTTPClient = func() *http.Client { return &http.Client{Transport: rt} }
t.Cleanup(func() { newRegistrationHTTPClient = orig })
}
// --- cache round-trip ---
func TestInitNoWaitCache_RoundTrip(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
ProfileName: "work",
Lang: "zh_cn",
LangExplicit: true,
Interval: 5,
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: "abc123",
}
const dc = "device-code-xyz"
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadInitNoWaitRecord(dc)
if err != nil {
t.Fatalf("load: %v", err)
}
if got == nil {
t.Fatal("load returned nil for a saved record")
}
if *got != rec {
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, rec)
}
if err := removeInitNoWaitRecord(dc); err != nil {
t.Fatalf("remove: %v", err)
}
got2, err := loadInitNoWaitRecord(dc)
if err != nil {
t.Fatalf("load after remove: %v", err)
}
if got2 != nil {
t.Errorf("expected nil after remove, got %+v", got2)
}
// Removing a non-existent record must be a no-op, not an error.
if err := removeInitNoWaitRecord(dc); err != nil {
t.Errorf("remove of missing record should be nil, got %v", err)
}
}
func TestInitNoWaitCache_LoadMissing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := loadInitNoWaitRecord("never-saved")
if err != nil {
t.Fatalf("load missing: %v", err)
}
if got != nil {
t.Errorf("expected nil for missing record, got %+v", got)
}
}
func TestInitNoWaitCache_VersionMismatchIgnored(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
const dc = "stale-version"
rec := initNoWaitRecord{Version: initNoWaitCacheVersion + 1, ExpiresAt: time.Now().Unix() + 300}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadInitNoWaitRecord(dc)
if err != nil {
t.Fatalf("load: %v", err)
}
if got != nil {
t.Errorf("expected nil for version mismatch, got %+v", got)
}
// The stale entry should have been discarded by the load.
got2, _ := loadInitNoWaitRecord(dc)
if got2 != nil {
t.Errorf("stale-version entry was not removed on load")
}
}
func TestInitNoWaitCacheKey(t *testing.T) {
// Distinct device codes that a char-replacement sanitizer would collide
// ("a/b" and "a:b" -> "a_b") must map to distinct keys.
if initNoWaitCacheKey("a/b") == initNoWaitCacheKey("a:b") {
t.Error("distinct device codes must not collide on the cache key")
}
// Deterministic.
if initNoWaitCacheKey("xyz") != initNoWaitCacheKey("xyz") {
t.Error("cache key must be deterministic")
}
// sha256 hex: 64 chars, filesystem-safe regardless of input.
k := initNoWaitCacheKey("has /, :, ;, spaces and 'quotes'")
if len(k) != 64 {
t.Errorf("expected 64-char sha256 hex key, got %d: %q", len(k), k)
}
}
// --- config digest ---
func TestComputeConfigDigest(t *testing.T) {
if d := computeConfigDigest(nil); d != "" {
t.Errorf("nil digest = %q, want empty", d)
}
cfg1 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
cfg1Dup := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
cfg2 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_b", Brand: core.BrandFeishu}}}
if computeConfigDigest(cfg1) == "" {
t.Error("non-nil config digest should be non-empty")
}
if computeConfigDigest(cfg1) != computeConfigDigest(cfg1Dup) {
t.Error("equal configs should produce equal digests")
}
if computeConfigDigest(cfg1) == computeConfigDigest(cfg2) {
t.Error("different configs should produce different digests")
}
}
// --- failure classification for cache cleanup ---
func TestAppRegShouldClearCache(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"success", nil, true},
{"denied", larkauth.ErrAppRegDenied, true},
{"expired", larkauth.ErrAppRegExpired, true},
{"expired wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegExpired), true},
{"timeout", larkauth.ErrAppRegTimeout, true},
{"timeout wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegTimeout), true},
{"cancelled", larkauth.ErrAppRegCancelled, false},
{"transient generic", fmt.Errorf("network boom"), false},
{"missing fields", fmt.Errorf("app registration succeeded but missing client_id or client_secret"), false},
}
for _, c := range cases {
if got := appRegShouldClearCache(c.err); got != c.want {
t.Errorf("%s: appRegShouldClearCache = %v, want %v", c.name, got, c.want)
}
}
}
// --- initiate (stubbed registration client) ---
func TestInitiateNoWaitAppRegistration_WritesCacheAndJSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
withStubRegistrationClient(t, stubRT{200, `{"device_code":"dc-abc","user_code":"U-1","verification_uri":"https://open.feishu.cn","expires_in":3600,"interval":5}`})
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true, ForceInit: true}
if err := initiateNoWaitAppRegistration(opts, nil); err != nil {
t.Fatalf("initiate: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("stdout not JSON: %v; raw=%s", err, stdout.String())
}
if out["device_code"] != "dc-abc" {
t.Errorf("device_code = %v, want dc-abc", out["device_code"])
}
args, ok := out["resume_args"].([]interface{})
if !ok || len(args) == 0 || args[len(args)-1] != "--force-init" {
t.Errorf("resume_args should end with --force-init, got %v", out["resume_args"])
}
rec, _ := loadInitNoWaitRecord("dc-abc")
if rec == nil {
t.Fatal("cache record not written")
}
if rec.Brand != "feishu" || rec.Version != initNoWaitCacheVersion {
t.Errorf("cache record = %+v", *rec)
}
}
// --- pollAppRegistrationResume (stubbed client) ---
func TestPollAppRegistrationResume_Success(t *testing.T) {
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`}}
res, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
t.Errorf("got %+v", res)
}
}
func TestPollAppRegistrationResume_MissingSecret(t *testing.T) {
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x"}`}}
if _, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard); err == nil {
t.Error("expected error when client_secret is missing")
}
}
func TestPollAppRegistrationResume_LarkRetry(t *testing.T) {
// First poll (feishu endpoint): lark tenant, no secret -> triggers re-poll
// against the lark endpoint, which returns the secret.
rt := &seqRT{bodies: []string{
`{"client_id":"cli_x","client_secret":"","user_info":{"tenant_brand":"lark"}}`,
`{"client_id":"cli_x","client_secret":"larksec","user_info":{"tenant_brand":"lark"}}`,
}}
res, err := pollAppRegistrationResume(context.Background(), &http.Client{Transport: rt}, "dc", 0, 60, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ClientSecret != "larksec" {
t.Errorf("expected lark re-poll to yield the secret, got %+v", res)
}
}
// Full resume happy path: stubbed poll succeeds, the app is persisted, and the
// cache is cleared. (runProbe hits the factory's mock client, which has no stub
// and returns an untyped error that runProbe swallows.)
func TestResumeAppRegistration_Success(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
const dc = "resume-ok"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
Interval: 1, // keep the single poll fast
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: computeConfigDigest(nil),
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
if err := resumeAppRegistration(opts); err != nil {
t.Fatalf("resume: %v", err)
}
cfg, _ := core.LoadMultiAppConfig()
if cfg == nil || cfg.CurrentAppConfig("") == nil || cfg.CurrentAppConfig("").AppId != "cli_new" {
t.Errorf("config not persisted with new app id: %+v", cfg)
}
if got, _ := loadInitNoWaitRecord(dc); got != nil {
t.Error("cache should be cleared after a successful save")
}
if !strings.Contains(stdout.String(), "cli_new") {
t.Errorf("stdout missing new appId: %s", stdout.String())
}
}
// A profile-name conflict on the resume save path must surface as the typed
// ValidationError(--name), not be downgraded to an internal/storage error.
func TestResumeAppRegistration_ProfileNameConflict_PreservesValidationError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
// Seed a config whose app id collides with the profile name we resume into.
seeded := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_existing", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu},
}}
if err := core.SaveMultiAppConfig(seeded); err != nil {
t.Fatalf("seed config: %v", err)
}
loaded, _ := core.LoadMultiAppConfig() // digest must match what resume recomputes
const dc = "conflict-dc"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
ProfileName: "cli_existing", // collides with the existing appId in saveAsProfile
Interval: 1,
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: computeConfigDigest(loaded),
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save cache: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
assertValidationParam(t, resumeAppRegistration(opts), "--name")
}
// --- flag validation (returns before any network) ---
func TestConfigInitRun_NoWaitAndDeviceCodeMutuallyExclusive(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, DeviceCode: "x"}
assertValidationParam(t, configInitRun(opts), "--device-code")
}
func TestConfigInitRun_NoWaitWithAppIDRejected(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, AppID: "cli_x"}
assertValidationParam(t, configInitRun(opts), "--no-wait")
}
// The conflict error must point at the flag the caller actually passed: with
// --device-code (not --no-wait) + --app-id, remediation should name --device-code.
func TestConfigInitRun_DeviceCodeWithAppIDReportsDeviceCode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "dc", AppID: "cli_x"}
assertValidationParam(t, configInitRun(opts), "--device-code")
}
// --- resume guards (return before any network) ---
func TestResumeAppRegistration_NoCacheEntry(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "missing-dc"}
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
}
func TestResumeAppRegistration_ExpiredClearsCache(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
const dc = "expired-dc"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
Interval: 5,
ExpiresAt: time.Now().Unix() - 10, // already past
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
if got, _ := loadInitNoWaitRecord(dc); got != nil {
t.Error("expired cache entry should have been removed")
}
}
// A cache file that exists but cannot be parsed is a storage failure, not a
// "no pending creation" validation error — the user should fix storage rather
// than assume the device code is bad.
func TestResumeAppRegistration_CorruptCacheIsStorageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
const dc = "corrupt-dc"
if err := os.MkdirAll(initNoWaitCacheDir(), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(initNoWaitCachePath(dc), []byte("{ not valid json"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
err := resumeAppRegistration(opts)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError for unreadable cache, got %T: %v", err, err)
}
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeStorage {
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeStorage, p)
}
if errors.Unwrap(err) == nil {
t.Fatal("expected the underlying cache-read failure to be preserved as a cause")
}
}
func TestResumeAppRegistration_ConfigDrift(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
const dc = "drift-dc"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
Interval: 5,
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: "stale-digest-that-will-not-match-current-config",
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
}

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -6,7 +6,6 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -14,24 +13,9 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// Sentinel errors returned by PollAppRegistration so callers can classify a
// failure (e.g. to decide whether a cached device code should be discarded)
// via errors.Is without parsing message strings.
var (
// ErrAppRegDenied means the user rejected the app registration.
ErrAppRegDenied = errors.New("app registration denied by user")
// ErrAppRegExpired means the device code is no longer valid.
ErrAppRegExpired = errors.New("device code expired")
// ErrAppRegCancelled means polling was cancelled via the context.
ErrAppRegCancelled = errors.New("polling was cancelled")
// ErrAppRegTimeout means the local polling deadline elapsed.
ErrAppRegTimeout = errors.New("app registration timed out")
)
// AppRegistrationResponse is the response from the app registration begin endpoint.
type AppRegistrationResponse struct {
DeviceCode string
@@ -79,7 +63,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
resp, err := httpClient.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "app registration request failed: %v", err).WithCause(err)
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
@@ -154,13 +138,13 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return nil, ErrAppRegCancelled
return nil, fmt.Errorf("polling was cancelled")
}
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return nil, ErrAppRegCancelled
return nil, fmt.Errorf("polling was cancelled")
}
form := url.Values{}
@@ -221,9 +205,9 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
return nil, ErrAppRegDenied
return nil, fmt.Errorf("app registration denied by user")
case "expired_token", "invalid_grant":
return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired)
return nil, fmt.Errorf("device code expired, please try again")
}
desc := getStr(data, "error_description")
@@ -239,5 +223,5 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
}
return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout)
return nil, fmt.Errorf("app registration timed out, please try again")
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
// stubRoundTripper returns a canned response for every request.
type stubRoundTripper struct {
status int
body string
}
func (s stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: s.status,
Body: io.NopCloser(strings.NewReader(s.body)),
Header: make(http.Header),
}, nil
}
// TestAppRegSentinelMessages locks the user-facing message text so the
// interactive create flow (which renders these via "%v") does not regress when
// the errors gained errors.Is support.
func TestAppRegSentinelMessages(t *testing.T) {
cases := map[string]string{
ErrAppRegDenied.Error(): "app registration denied by user",
ErrAppRegCancelled.Error(): "polling was cancelled",
fmt.Errorf("%w, please try again", ErrAppRegExpired).Error(): "device code expired, please try again",
fmt.Errorf("%w, please try again", ErrAppRegTimeout).Error(): "app registration timed out, please try again",
}
for got, want := range cases {
if got != want {
t.Errorf("message = %q, want %q", got, want)
}
}
}
// TestPollAppRegistration_Classifies verifies that terminal poll outcomes are
// returned as the matching sentinel error (interval 0 keeps the test fast).
func TestPollAppRegistration_Classifies(t *testing.T) {
cases := []struct {
name string
body string
want error
}{
{"access_denied", `{"error":"access_denied"}`, ErrAppRegDenied},
{"expired_token", `{"error":"expired_token"}`, ErrAppRegExpired},
{"invalid_grant", `{"error":"invalid_grant"}`, ErrAppRegExpired},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
client := &http.Client{Transport: stubRoundTripper{status: 200, body: c.body}}
_, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
if !errors.Is(err, c.want) {
t.Fatalf("err = %v, want errors.Is(%v)", err, c.want)
}
})
}
}
func TestPollAppRegistration_Success(t *testing.T) {
body := `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu","open_id":"ou_1"}}`
client := &http.Client{Transport: stubRoundTripper{status: 200, body: body}}
res, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
t.Errorf("got client_id=%q secret=%q, want cli_x/sec", res.ClientID, res.ClientSecret)
}
if res.UserInfo == nil || res.UserInfo.TenantBrand != "feishu" {
t.Errorf("user info not parsed: %+v", res.UserInfo)
}
}
func TestPollAppRegistration_CancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel up front
client := &http.Client{Transport: stubRoundTripper{status: 200, body: `{"error":"authorization_pending"}`}}
_, err := PollAppRegistration(ctx, client, core.BrandFeishu, "dc", 0, 60, io.Discard)
if !errors.Is(err, ErrAppRegCancelled) {
t.Fatalf("err = %v, want errors.Is(ErrAppRegCancelled)", err)
}
}

View File

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

View File

@@ -89,7 +89,7 @@ def main():
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w") as fp:
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"strings"
"testing"
)
func TestConvertInteractiveEventContent(t *testing.T) {
// invalid JSON → fallback
if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
t.Fatalf("invalid JSON = %q, want [interactive card]", got)
}
// missing user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
}
// empty user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
}
// user_dsl that is not a string (wrong type) → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
}
// valid user-2 card → <card> output
userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
got := ConvertInteractiveEventContent(rawContent, nil)
if !strings.HasPrefix(got, `<card title="Hello">`) {
t.Fatalf("valid card = %q, want prefix <card title=\"Hello\">", got)
}
if !strings.Contains(got, "world") {
t.Fatalf("valid card = %q, want to contain 'world'", got)
}
}
func makeMentionCard(mdContent string) string {
obj := map[string]interface{}{
"schema": "2.0",
"header": map[string]interface{}{
"title": map[string]interface{}{"tag": "plain_text", "content": "T"},
},
"body": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{"tag": "markdown", "content": mdContent},
},
},
}
dslBytes, _ := json.Marshal(obj)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
return string(raw)
}
func TestConvertInteractiveEventContentMentions(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// quoted attrs: mention_key="key"
got := ConvertInteractiveEventContent(makeMentionCard(`hi <at mention_key="@_user_1">n</at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("quoted mention_key not resolved, got: %s", got)
}
// unquoted attrs (real Lark format): <at id=ou_xxx mention_key=@_user_1></at>
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mention_key not resolved, got: %s", got)
}
// mentions_key variant (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at mentions_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
}
// degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
}
// degradation 2: mention_key not found in mentions → fall back to @id
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_unknown></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
}
// multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
// k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
got = ConvertInteractiveEventContent(
makeMentionCard(`<at ids=fake-uid-001,fake-uid-002,fake-uid-003 mentions_key=@_user_1,,@_unknown></at>`),
mentions,
)
want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
if !strings.Contains(got, want) {
t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
}
}
func TestUserDslConverterSchema(t *testing.T) {
c := &userDslConverter{}
// user-2.ts: schema field present, header at root, body.elements
schema2 := cardObj{
"schema": "2.0",
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
},
"body": cardObj{
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "body text"},
},
},
}
got := c.convert(schema2)
if got != "<card title=\"Schema2 Title\" subtitle=\"Sub\">\nbody text\n</card>" {
t.Fatalf("schema2 = %q", got)
}
// user-1.ts: no schema field, i18n_header.zh_cn, elements at root
schema1 := cardObj{
"i18n_header": cardObj{
"zh_cn": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
},
},
"elements": []interface{}{
cardObj{"tag": "hr"},
},
}
got = c.convert(schema1)
if got != "<card title=\"Schema1 Title\">\n---\n</card>" {
t.Fatalf("schema1 = %q", got)
}
// user-1.ts: no schema, direct header (real Lark event format)
schema1Direct := cardObj{
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "direct body"},
},
}
got = c.convert(schema1Direct)
if got != "<card title=\"Direct Header Title\" subtitle=\"Direct Sub\">\ndirect body\n</card>" {
t.Fatalf("schema1 direct header = %q", got)
}
// no header, no elements → fallback
got = c.convert(cardObj{})
if got != "[interactive card]" {
t.Fatalf("empty card = %q, want [interactive card]", got)
}
// card with title only → valid (not "[interactive card]")
titleOnly := cardObj{
"schema": "2.0",
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
"body": cardObj{"elements": []interface{}{}},
}
got = c.convert(titleOnly)
if !strings.Contains(got, "TitleOnly") {
t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
}
}
func TestUserDslConverterDispatch(t *testing.T) {
c := &userDslConverter{}
tests := []struct {
name string
elem cardObj
want string
contains string
}{
{
name: "plain_text",
elem: cardObj{"tag": "plain_text", "content": "hello"},
want: "hello",
},
{
name: "markdown",
elem: cardObj{"tag": "markdown", "content": "**bold**"},
want: "**bold**",
},
{
name: "hr",
elem: cardObj{"tag": "hr"},
want: "---",
},
{
name: "br",
elem: cardObj{"tag": "br"},
want: "\n",
},
{
name: "img with img_key",
elem: cardObj{
"tag": "img",
"img_key": "img_v3_abc",
"alt": cardObj{"tag": "plain_text", "content": "Banner"},
},
want: "🖼️ Banner(img_key:img_v3_abc)",
},
{
name: "img_combination",
elem: cardObj{
"tag": "img_combination",
"img_list": []interface{}{
cardObj{"img_key": "k1"},
cardObj{"img_key": "k2"},
},
},
want: "🖼️ 2 image(s)(keys:k1,k2)",
},
{
name: "button with behaviors default_url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Open"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
},
want: "[Open](https://example.com)",
},
{
name: "button disabled",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Nope"},
"disabled": true,
},
want: "[Nope ✗]",
},
{
name: "button no url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
},
want: "[Submit]",
},
{
name: "action wrapper (user-1 style)",
elem: cardObj{
"tag": "action",
"actions": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
},
},
want: "[A] [B]",
},
{
name: "overflow",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
},
},
want: "⋮ Edit, Delete",
},
{
name: "select_static no selection",
elem: cardObj{
"tag": "select_static",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / Option2 ▼}",
},
{
name: "select_static with initial_option",
elem: cardObj{
"tag": "select_static",
"initial_option": "2",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / ✓Option2}",
},
{
name: "multi_select_static with selected_values",
elem: cardObj{
"tag": "multi_select_static",
"selected_values": []interface{}{"A"},
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
},
},
want: "{✓OptA / OptB}(multi)",
},
{
name: "select_person no options no selection shows placeholder",
elem: cardObj{
"tag": "select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
},
want: "{请选择 ▼}",
},
{
name: "select_person with initial_option synthesizes from ID",
elem: cardObj{
"tag": "select_person",
"initial_option": "fake-open-id-001",
},
want: "{✓fake-open-id-001}",
},
{
name: "multi_select_person with selected_values shows IDs and multi",
elem: cardObj{
"tag": "multi_select_person",
"selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
},
want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
},
{
name: "multi_select_person no selection shows placeholder",
elem: cardObj{
"tag": "multi_select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
},
want: "{添加人员 ▼}(multi)",
},
{
name: "input with default_value",
elem: cardObj{
"tag": "input",
"label": cardObj{"tag": "plain_text", "content": "Reason"},
"default_value": "prefilled",
},
want: "Reason: prefilled___",
},
{
name: "input with placeholder",
elem: cardObj{
"tag": "input",
"placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
},
want: "Type here_____",
},
{
name: "date_picker with initial_date",
elem: cardObj{
"tag": "date_picker",
"initial_date": "2026-01-01",
},
want: "📅 2026-01-01",
},
{
name: "date_picker placeholder",
elem: cardObj{
"tag": "date_picker",
"placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
},
want: "📅 Pick date",
},
{
name: "picker_time with initial_time",
elem: cardObj{
"tag": "picker_time",
"initial_time": "14:30",
},
want: "🕐 14:30",
},
{
name: "checker unchecked",
elem: cardObj{
"tag": "checker",
"text": cardObj{"tag": "plain_text", "content": "Task A"},
},
want: "[ ] Task A",
},
{
name: "checker checked",
elem: cardObj{
"tag": "checker",
"checked": true,
"text": cardObj{"tag": "plain_text", "content": "Task B"},
},
want: "[x] Task B",
},
{
name: "chart with chart_spec",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": "month",
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "value": float64(10)},
cardObj{"month": "Feb", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart with compound xField array",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": []interface{}{"month", "category"},
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "category": "A", "value": float64(10)},
cardObj{"month": "Feb", "category": "B", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart no custom title uses type name",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "pie",
"categoryField": "label",
"valueField": "val",
"data": cardObj{"values": []interface{}{
cardObj{"label": "A", "val": float64(1)},
}},
},
},
want: "📊 Pie chart\nSummary: A:1",
},
{
name: "chart vchart array data format",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "bar",
"xField": "x",
"yField": "y",
"data": []interface{}{
cardObj{"id": "s1", "values": []interface{}{
cardObj{"x": "Jan", "y": float64(5)},
}},
cardObj{"id": "s2", "values": []interface{}{
cardObj{"x": "Feb", "y": float64(8)},
}},
},
},
},
want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
},
{
name: "text_tag",
elem: cardObj{
"tag": "text_tag",
"text": cardObj{"tag": "plain_text", "content": "新功能"},
},
want: "「新功能」",
},
{
name: "avatar with user_id",
elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
want: "👤(id:fake-open-id-001)",
},
{
name: "avatar no user_id",
elem: cardObj{"tag": "avatar"},
want: "👤",
},
{
name: "select_img no selection",
elem: cardObj{
"tag": "select_img",
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "select_img with selected",
elem: cardObj{
"tag": "select_img",
"selected_values": []interface{}{"v1"},
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "repeat delegates to elements",
elem: cardObj{
"tag": "repeat",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "item A"},
cardObj{"tag": "markdown", "content": "item B"},
},
},
want: "item A\nitem B",
},
{
name: "audio with file_key",
elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
want: "🎵 Audio(key:file_abc123)",
},
{
name: "audio fallback audio_id",
elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
want: "🎵 Audio(key:audio_xyz)",
},
{
name: "video with file_key",
elem: cardObj{"tag": "video", "file_key": "video_abc"},
want: "🎬 Video(key:video_abc)",
},
{
name: "custom_icon returns empty",
elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
want: "",
},
{
name: "standard_icon returns empty",
elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
want: "",
},
{
name: "button disabled with disabled_tips",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
"disabled": true,
"disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
},
want: "[Submit ✗](tips:\"Not allowed\")",
},
{
name: "button with confirm",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Delete"},
"confirm": cardObj{
"title": cardObj{"tag": "plain_text", "content": "确认"},
"text": cardObj{"tag": "plain_text", "content": "不可撤销"},
},
},
want: "[Delete](confirm:\"确认: 不可撤销\")",
},
{
name: "overflow with url",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
},
},
want: "⋮ [Open](https://example.com), Copy(copy)",
},
{
name: "select_static with initial_index",
elem: cardObj{
"tag": "select_static",
"initial_index": float64(1),
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
},
},
want: "{First / ✓Second}",
},
{
name: "div text with notation size",
elem: cardObj{
"tag": "div",
"text": cardObj{
"tag": "plain_text",
"content": "小字注释",
"text_size": "notation",
},
},
want: "📝 小字注释",
},
{
name: "form",
elem: cardObj{
"tag": "form",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "fill this"},
},
},
want: "<form>\nfill this\n</form>",
},
{
name: "collapsible_panel collapsed",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": false,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▶ Details\n inner\n▲",
},
{
name: "collapsible_panel expanded",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": true,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▼ Details\n inner\n▲",
},
{
name: "interactive_container with behaviors",
elem: cardObj{
"tag": "interactive_container",
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "Click here"},
},
},
want: "<clickable url=\"https://example.com\">\nClick here\n</clickable>",
},
{
name: "interactive_container no url",
elem: cardObj{
"tag": "interactive_container",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "No link"},
},
},
want: "<clickable>\nNo link\n</clickable>",
},
{
name: "column_set with buttons → space-joined",
elem: cardObj{
"tag": "column_set",
"columns": []interface{}{
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
}},
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
}},
},
},
want: "[X] [Y]",
},
{
name: "person",
elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
want: "fake-open-id-002",
},
{
name: "unknown tag fallback to content",
elem: cardObj{"tag": "mystery", "content": "mystery content"},
want: "mystery content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.convertElement(tt.elem, 0)
if tt.contains != "" {
if !strings.Contains(got, tt.contains) {
t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
}
return
}
if got != tt.want {
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}
func TestUserDslExtractButtonURL(t *testing.T) {
c := &userDslConverter{}
// direct url field wins first
got := c.extractButtonURL(cardObj{
"url": "https://example.com/direct",
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/direct" {
t.Fatalf("direct url = %q, want https://example.com/direct", got)
}
// multi_url.url when no direct url
got = c.extractButtonURL(cardObj{
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/multi" {
t.Fatalf("multi_url = %q, want https://example.com/multi", got)
}
// behaviors default_url as last resort
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/behavior" {
t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
}
// non-open_url behavior is ignored
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "callback", "default_url": "https://example.com/callback"},
},
})
if got != "" {
t.Fatalf("non-open_url = %q, want empty", got)
}
// no url anywhere → empty
got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
if got != "" {
t.Fatalf("no url = %q, want empty", got)
}
}
func TestUserDslExtractTableCellValue(t *testing.T) {
c := &userDslConverter{}
// nil
if got := c.extractUserDslTableCellValue(nil); got != "" {
t.Fatalf("nil = %q, want empty", got)
}
// string
if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
t.Fatalf("string = %q, want 'hello'", got)
}
// float64 integer
if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
t.Fatalf("int float = %q, want '42'", got)
}
// float64 decimal
if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
t.Fatalf("float = %q, want '3.14'", got)
}
// []interface{} with text tags → 「text」 format
got := c.extractUserDslTableCellValue([]interface{}{
cardObj{"text": "S2", "color": "blue"},
cardObj{"text": "M1", "color": "red"},
})
if got != "「S2」 「M1」" {
t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
}
// cardObj with content field
got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
if got != "cell content" {
t.Fatalf("cardObj with content = %q, want 'cell content'", got)
}
}
func TestUserDslConvertTable(t *testing.T) {
c := &userDslConverter{}
got := c.convertTable(cardObj{
"columns": []interface{}{
cardObj{"display_name": "客户名称", "name": "customer_name"},
cardObj{"display_name": "规模", "name": "scale"},
cardObj{"display_name": "金额", "name": "arr"},
},
"rows": []interface{}{
cardObj{
"customer_name": "飞书科技",
"scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
"arr": float64(16800),
},
},
})
want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
if got != want {
t.Fatalf("convertTable() = %q, want %q", got, want)
}
// no columns → empty
if got := c.convertTable(cardObj{}); got != "" {
t.Fatalf("no columns = %q, want empty", got)
}
}
func TestLarkMdMentionResolution(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// lark_md in div.text — the real Lark event format (C01 case)
card := map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": "Hello <at id=fake-uid-001></at> check this.",
},
},
},
}
dslBytes, _ := json.Marshal(card)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got := ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("div.text lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
}
// lark_md in note.elements (C02 case)
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "lark_md",
"content": "Note: <at id=fake-uid-001></at> check.",
},
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("note lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("note lark_md: @id not in output, got: %s", got)
}
// mention_key resolution via mentions map
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": `Hi <at mention_key="@_user_1">n</at> done.`,
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
}
}
func TestConvertUserDslCardEndToEnd(t *testing.T) {
// user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
schema2JSON := `{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
"template": "blue"
},
"body": {
"elements": [
{"tag": "markdown", "content": "### 基础文本"},
{"tag": "hr"},
{
"tag": "img",
"img_key": "img_v3_02122_abc",
"alt": {"tag": "plain_text", "content": "示例图片"}
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "主要按钮"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
},
{
"tag": "table",
"columns": [
{"display_name": "名称", "name": "name"},
{"display_name": "数值", "name": "value"}
],
"rows": [
{"name": "项目A", "value": 100},
{"name": "项目B", "value": 200}
]
}
]
}
}`
got := convertUserDslCard(schema2JSON, nil)
if !strings.HasPrefix(got, `<card title="飞书卡片组件展示">`) {
t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
}
if !strings.Contains(got, "### 基础文本") {
t.Fatal("e2e schema2: missing markdown content")
}
if !strings.Contains(got, "---") {
t.Fatal("e2e schema2: missing hr")
}
if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
t.Fatalf("e2e schema2: missing image, got: %s", got)
}
if !strings.Contains(got, "[主要按钮](https://example.com)") {
t.Fatalf("e2e schema2: missing button, got: %s", got)
}
if !strings.Contains(got, "| 名称 | 数值 |") {
t.Fatal("e2e schema2: missing table header")
}
if !strings.Contains(got, "| 项目A | 100 |") {
t.Fatalf("e2e schema2: missing table row, got: %s", got)
}
if !strings.HasSuffix(got, "</card>") {
t.Fatalf("e2e schema2: missing </card> suffix, got: %s", got)
}
// user-1.ts format
schema1JSON := `{
"i18n_header": {
"zh_cn": {
"title": {"tag": "plain_text", "content": "Schema1 卡片"},
"template": "blue"
}
},
"elements": [
{"tag": "markdown", "content": "Hello **World**"},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "跳转"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
}
]
}
]
}`
got = convertUserDslCard(schema1JSON, nil)
if !strings.HasPrefix(got, `<card title="Schema1 卡片">`) {
t.Fatalf("e2e schema1: missing card title, got: %s", got)
}
if !strings.Contains(got, "Hello **World**") {
t.Fatal("e2e schema1: missing markdown")
}
if !strings.Contains(got, "[跳转](https://example.com)") {
t.Fatalf("e2e schema1: missing button, got: %s", got)
}
}

View File

@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
}
got := extractPostBlocksText(blocks)
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
want := "hello @Alice [docs](https://example.com)\n![Image](img_123)"
if got != want {
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
}

View File

@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
if title, _ := body["title"].(string); title != "" {
parts = append(parts, title)
}
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
// Prefer content_v2 blocks; fallback to content blocks
blocks := selectContentBlocks(body)
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
}
result := strings.TrimSpace(strings.Join(parts, "\n"))
@@ -58,6 +58,17 @@ func (postConverter) Convert(ctx *ConvertContext) string {
return ResolveMentionKeys(result, ctx.MentionMap)
}
// selectContentBlocks returns content_v2 blocks when present and non-empty;
// otherwise falls back to content blocks. This implements the content_v2
// priority rule for post messages.
func selectContentBlocks(body map[string]interface{}) []interface{} {
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
return v2
}
blocks, _ := body["content"].([]interface{})
return blocks
}
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
if _, ok := parsed["content"]; ok {
return parsed
@@ -114,10 +125,14 @@ func renderPostElem(el map[string]interface{}) string {
var rendered string
switch {
case userId == "@_all" || userId == "all":
rendered = "@all"
rendered = `<at user_id="all"></at>`
default:
if name, _ := el["user_name"].(string); name != "" {
rendered = "@" + name
if userId != "" && strings.HasPrefix(userId, "ou") {
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
} else {
rendered = "@" + name
}
} else {
rendered = "@" + userId
}
@@ -138,7 +153,7 @@ func renderPostElem(el map[string]interface{}) string {
case "img":
key, _ := el["image_key"].(string)
if key != "" {
return fmt.Sprintf("[Image: %s]", key)
return fmt.Sprintf("![Image](%s)", key)
}
return "[Image]"
case "media":

View File

@@ -93,9 +93,13 @@ func TestRenderPostElem(t *testing.T) {
}{
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "![Image](img_123)"},
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
@@ -144,3 +148,87 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
})
}
}
func TestSelectContentBlocks(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
want int
}{
{
name: "content_v2 present and non-empty",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
},
want: 1,
},
{
name: "content_v2 empty array",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{},
},
want: 1,
},
{
name: "content_v2 nil",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
},
want: 1,
},
{
name: "content_v2 wrong type",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": "not_an_array",
},
want: 1,
},
{
name: "both missing",
body: map[string]interface{}{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectContentBlocks(tt.body)
if len(got) != tt.want {
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
}
})
}
}
func TestPostConverterConvertContentV2(t *testing.T) {
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
ctx := &ConvertContext{
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
}
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
if got := (postConverter{}).Convert(ctx); got != want {
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
}
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
ctx2 := &ConvertContext{
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
}
want2 := `<at user_id="ou_xxx">Bob</at> ![Image](img_123)`
if got := (postConverter{}).Convert(ctx2); got != want2 {
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
}
// AC-M1-E1: content_v2 empty → fallback to content
ctx3 := &ConvertContext{
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
}
want3 := "fallback path"
if got := (postConverter{}).Convert(ctx3); got != want3 {
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
}
}

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// batchCreateKR represents a key result in the batch create input.
type batchCreateKR struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
}
// batchCreateObjective represents an objective in the batch create input.
type batchCreateObjective struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
KRs []batchCreateKR `json:"krs,omitempty"`
}
// createdObjective tracks a created objective and its KR IDs for output.
// KRs are automatically deleted by the backend when the objective is deleted (no need to delete them separately during rollback).
type createdObjective struct {
ObjectiveID string
KRIDs []string // for output response only, not used in rollback
}
// parseBatchCreateInput parses and validates the JSON input.
func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
var objectives []batchCreateObjective
if err := json.Unmarshal([]byte(input), &objectives); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be valid JSON array: %s", err).WithParam("--input").WithCause(err)
}
if len(objectives) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must contain at least one objective").WithParam("--input")
}
for i, obj := range objectives {
if strings.TrimSpace(obj.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
}
for j, kr := range obj.KRs {
if strings.TrimSpace(kr.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
}
}
}
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)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create objective")
}
objectiveID, ok := data["objective_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
}
return objectiveID, nil
}
// 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)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create key result")
}
krID, ok := data["key_result_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
}
return krID, nil
}
// deleteObjective deletes an objective (used for rollback).
func deleteObjective(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) error {
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"yes": true,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s", objectiveID)
_, err := runtime.CallAPITyped("DELETE", path, queryParams, nil)
if err != nil {
return wrapOkrNetworkErr(err, "failed to delete objective %s during rollback", objectiveID)
}
return nil
}
// rollback deletes created objectives in reverse order.
// KRs are automatically deleted by the backend when the objective is deleted.
func rollback(ctx context.Context, runtime *common.RuntimeContext, created []createdObjective) []error {
var errsList []error
// Iterate in reverse order
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete the objective (backend automatically deletes its KRs)
if err := deleteObjective(ctx, runtime, obj.ObjectiveID); err != nil {
//nolint:forbidigo // intermediate wrap for rollback error collection; final error is typed via buildRollbackError
errsList = append(errsList, fmt.Errorf("objective %s: %w", obj.ObjectiveID, err))
}
// Rate limiting between deletions
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
}
return errsList
}
// OKRBatchCreate batch creates objectives and their key results.
var OKRBatchCreate = common.Shortcut{
Service: "okr",
Command: "+batch-create",
Description: "Batch create OKR objectives and key results with rollback on failure",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
{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 {
cycleID := runtime.Str("cycle-id")
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")
}
input := runtime.Str("input")
if err := common.RejectDangerousCharsTyped("--input", input); err != nil {
return err
}
if _, err := parseBatchCreateInput(input); err != nil {
return err
}
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")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
apis := common.NewDryRunAPI()
for i, obj := range objectives {
// Objective creation
objContent := buildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
objParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
objPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
POST(objPath).
Params(objParams).
Body(objBody).
Desc(fmt.Sprintf("Create objective[%d]: %s", i, obj.Text))
// KR creations
for j, kr := range obj.KRs {
krContent := buildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}
krParams := map[string]interface{}{
"objective_id": "<objective_id_from_previous_call>",
"user_id_type": userIDType,
}
krPath := "/open-apis/okr/v2/objectives/<objective_id>/key_results"
apis = apis.
POST(krPath).
Params(krParams).
Body(krBody).
Desc(fmt.Sprintf("Create objective[%d].krs[%d]: %s", i, j, kr.Text))
}
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, err := parseBatchCreateInput(runtime.Str("input"))
if err != nil {
return err
}
var created []createdObjective
for i, obj := range objectives {
// Rate limiting between objectives
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
// Create objective
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
if err != nil {
if len(created) == 0 {
return err
}
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj := createdObjective{
ObjectiveID: objectiveID,
}
// Create KRs
for j, kr := range obj.KRs {
// Rate limiting between KRs
if j > 0 {
time.Sleep(500 * time.Millisecond)
}
krID, err := createKR(ctx, runtime, objectiveID, userIDType, kr)
if err != nil {
created = append(created, createdObj)
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj.KRIDs = append(createdObj.KRIDs, krID)
}
created = append(created, createdObj)
}
// Build response
respCreated := make([]map[string]interface{}, 0, len(created))
for _, obj := range created {
respCreated = append(respCreated, map[string]interface{}{
"objective_id": obj.ObjectiveID,
"krs": obj.KRIDs,
})
}
result := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{"created": respCreated},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully created %d objective(s)\n", len(created))
for i, obj := range created {
fmt.Fprintf(w, "Objective[%d] ID: %s (%d KR(s))\n", i, obj.ObjectiveID, len(obj.KRIDs))
for j, krID := range obj.KRIDs {
fmt.Fprintf(w, " KR[%d] ID: %s\n", j, krID)
}
}
})
return nil
},
}
// buildRollbackError constructs an error that includes both the original failure
// and any rollback failures, with a list of residual IDs that could not be cleaned up.
// KRs are automatically deleted by the backend when the objective is deleted, so we only
// need to track objective IDs for residual cleanup.
func buildRollbackError(originalErr error, rollbackErrs []error, created []createdObjective) error {
var residualIDs []string
// Only collect residual IDs when rollback had failures
// If rollback succeeded (len(rollbackErrs) == 0), all objectives were deleted
if len(rollbackErrs) > 0 {
for _, obj := range created {
residualIDs = append(residualIDs, fmt.Sprintf("objective:%s", obj.ObjectiveID))
}
}
msg := fmt.Sprintf("batch create failed, rolling back: %v", originalErr)
if len(rollbackErrs) > 0 {
var rollbackMsgs []string
for _, e := range rollbackErrs {
rollbackMsgs = append(rollbackMsgs, e.Error())
}
msg += fmt.Sprintf("; rollback also had %d failure(s): %s", len(rollbackErrs), strings.Join(rollbackMsgs, "; "))
}
if len(residualIDs) > 0 {
msg += fmt.Sprintf("; residual objectives that may need manual cleanup (KRs auto-deleted with objective): %s", strings.Join(residualIDs, ", "))
}
// Preserve the original error's type information if it's already a typed error
if prob, ok := errs.ProblemOf(originalErr); ok {
switch prob.Category {
case errs.CategoryAPI:
return errs.NewAPIError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryNetwork:
return errs.NewNetworkError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryValidation:
return errs.NewValidationError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryInternal:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
default:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
}
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithCause(originalErr)
}

View File

@@ -0,0 +1,593 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-batch-create",
AppSecret: "secret-okr-batch-create",
Brand: core.BrandFeishu,
}
}
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRBatchCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
const validBatchCreateInput = `[
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--input", validBatchCreateInput,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "abc",
"--input", validBatchCreateInput,
})
if err == nil {
t.Fatal("expected error for invalid --cycle-id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_MissingInput(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "input") {
t.Fatalf("expected --input required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --input JSON")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "[]",
})
if err == nil {
t.Fatal("expected error for empty --input array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for empty objective text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].text") {
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
})
if err == nil {
t.Fatal("expected error for empty KR text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--user-id-type", "invalid",
})
if err == nil {
t.Fatal("expected error for invalid --user-id-type")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--user-id-type" {
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "101",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "201",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "202",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- DryRun tests ---
func TestBatchCreateDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
}
// Verify content is in the body
if !strings.Contains(output, "Objective 1") {
t.Fatalf("dry-run output should contain objective text, got: %s", output)
}
if !strings.Contains(output, "KR 1.1") {
t.Fatalf("dry-run output should contain KR text, got: %s", output)
}
}
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
ok, _ := data["ok"].(bool)
if !ok {
t.Fatal("expected ok=true in output")
}
dataField, _ := data["data"].(map[string]interface{})
created, _ := dataField["created"].([]interface{})
if len(created) != 1 {
t.Fatalf("expected 1 created objective, got %d", len(created))
}
obj, _ := created[0].(map[string]interface{})
if obj["objective_id"] != "100" {
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
}
krs, _ := obj["krs"].([]interface{})
if len(krs) != 1 || krs[0] != "200" {
t.Fatalf("expected krs=[200], got %v", krs)
}
}
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1"}]`,
})
if err == nil {
t.Fatal("expected error for API failure")
}
// Should be a typed error from the API
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != "api" {
t.Fatalf("expected api category, got %q", prob.Category)
}
}
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// First objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback: delete the created objective
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention rollback
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
t.Fatalf("expected error to mention rollback, got: %v", err)
}
// Assert typed error metadata
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
}
// Assert cause preservation
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to wrap APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("expected errors.Is to find the wrapped APIError")
}
}
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// Objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback delete also fails
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Status: 500,
Body: map[string]interface{}{
"code": 9999999,
"msg": "internal error",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention residual resources
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
t.Fatalf("expected error to mention residual resources, got: %v", err)
}
if !strings.Contains(err.Error(), "objective:100") {
t.Fatalf("expected error to list residual objective ID, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseBatchCreateInput_Valid(t *testing.T) {
t.Parallel()
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
objs, err := parseBatchCreateInput(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("expected 1 objective, got %d", len(objs))
}
if objs[0].Text != "Obj 1" {
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
}
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
}
if len(objs[0].KRs) != 1 {
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
}
if objs[0].KRs[0].Text != "KR 1" {
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
}
}
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
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.Fatalf("expected paragraph block type")
}
if block.Paragraph == nil {
t.Fatal("expected non-nil paragraph")
}
// Should have 3 elements: 1 text + 2 mentions
if len(block.Paragraph.Elements) != 3 {
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
}
// First element should be textRun
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
}
// Second and third should be mentions
for i := 1; i <= 2; i++ {
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
t.Fatalf("expected element %d to be mention", i)
}
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"fmt"
"io"
"math"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// parseIndicatorValue parses and validates the indicator value.
func parseIndicatorValue(valueStr string) (float64, error) {
value, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value").WithCause(err)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < -99999999999 || value > 99999999999 {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value")
}
return value, nil
}
// fetchIndicatorID fetches the indicator ID for an objective or key result.
// The indicators.list API returns a single indicator object (not a list),
// which always exists (may be a default empty indicator).
func fetchIndicatorID(ctx context.Context, runtime *common.RuntimeContext, level string, id string) (string, error) {
var path string
var params map[string]interface{}
if level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
}
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to fetch indicators")
}
// Parse response to get indicator ID
// Response format: {"indicator": {"id": "...", ...}} (single object, not a list)
indicator, ok := data["indicator"].(map[string]interface{})
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator field not found in response")
}
indicatorID, ok := indicator["id"].(string)
if !ok || indicatorID == "" {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator ID not found or empty")
}
return indicatorID, nil
}
// OKRIndicatorUpdate updates the current value of an indicator for an objective or key result.
var OKRIndicatorUpdate = common.Shortcut{
Service: "okr",
Command: "+indicator-update",
Description: "Update the indicator current value for an objective or key result",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to update: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "id", Desc: "objective or key result ID (int64), Required."},
{Name: "value", Desc: "new current value for the indicator (number), Required."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
id := runtime.Str("id")
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id is required").WithParam("--id")
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id must be a valid int64").WithParam("--id")
}
if runtime.Str("value") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
}
if _, err := parseIndicatorValue(runtime.Str("value")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
id := runtime.Str("id")
value, _ := parseIndicatorValue(runtime.Str("value"))
apis := common.NewDryRunAPI()
var listPath string
if level == "objective" {
listPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
} else {
listPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
}
// First API: fetch indicator list
apis = apis.
GET(listPath).
Params(map[string]interface{}{"page_size": 100}).
Desc(fmt.Sprintf("Fetch indicators for the %s to get indicator ID", level))
// Second API: patch indicator value
patchPath := "/open-apis/okr/v2/indicators/:indicator_id"
patchBody := map[string]interface{}{
"current_value": value,
}
apis = apis.
PATCH(patchPath).
Body(patchBody).
Set("indicator_id", "<indicator_id_from_list>").
Desc("Update indicator current value")
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
id := runtime.Str("id")
value, err := parseIndicatorValue(runtime.Str("value"))
if err != nil {
return err
}
// Step 1: Fetch indicator ID
indicatorID, err := fetchIndicatorID(ctx, runtime, level, id)
if err != nil {
return err
}
// Step 2: Update indicator value
patchPath := fmt.Sprintf("/open-apis/okr/v2/indicators/%s", indicatorID)
patchBody := map[string]interface{}{
"current_value": value,
}
_, err = runtime.CallAPITyped("PATCH", patchPath, nil, patchBody)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update indicator value")
}
// Build response
result := map[string]interface{}{
"indicator_id": indicatorID,
"current_value": value,
"level": level,
"target_id": id,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Indicator [%s]\n", indicatorID)
fmt.Fprintf(w, " Level: %s\n", level)
fmt.Fprintf(w, " Target ID: %s\n", id)
fmt.Fprintf(w, " Current Value: %v\n", value)
})
return nil
},
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func indicatorUpdateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-indicator-update",
AppSecret: "secret-okr-indicator-update",
Brand: core.BrandFeishu,
}
}
func runIndicatorUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRIndicatorUpdate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestIndicatorUpdateValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--id", "123",
"--value", "50",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "invalid",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--value", "50",
})
if err == nil || !strings.Contains(err.Error(), "id") {
t.Fatalf("expected --id required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "not-a-number",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--id" {
t.Fatalf("expected param --id, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
})
if err == nil || !strings.Contains(err.Error(), "value") {
t.Fatalf("expected --value required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "not-a-number",
})
if err == nil {
t.Fatal("expected error for invalid value")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--value" {
t.Fatalf("expected param --value, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- Execute tests ---
func TestIndicatorUpdateExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
val, ok := data["current_value"].(float64)
return ok && val == 75.5
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/key_results/456/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-789",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-789",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "key-result",
"--id", "456",
"--value", "100",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_FetchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators - API error
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 9999,
"msg": "fetch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for fetch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
func TestIndicatorUpdateExecute_PatchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator - API error
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 9999,
"msg": "patch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for patch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
// --- parseIndicatorValue tests ---
func TestParseIndicatorValue_Valid(t *testing.T) {
t.Parallel()
tests := []string{"0", "100", "75.5", "-10", "0.001", "99999999999"}
for _, v := range tests {
result, err := parseIndicatorValue(v)
if err != nil {
t.Fatalf("expected no error for %q, got: %v", v, err)
}
_ = result
}
}
func TestParseIndicatorValue_Invalid(t *testing.T) {
t.Parallel()
tests := []string{"", "abc", "1e100000", "100000000000"}
for _, v := range tests {
_, err := parseIndicatorValue(v)
if err == nil {
t.Fatalf("expected error for %q", v)
}
}
}

View File

@@ -0,0 +1,448 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// reorderItem is the interface for items that have an ID.
type reorderItem interface {
GetID() string
}
// reorderOp represents a single reorder operation.
type reorderOp struct {
ID string `json:"id"`
Position int32 `json:"position"`
}
// parseReorderOps parses and validates the --ops JSON array.
func parseReorderOps(opsStr string) ([]reorderOp, error) {
var ops []reorderOp
if err := json.Unmarshal([]byte(opsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must be valid JSON array: %s", err).WithParam("--ops").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must contain at least one operation").WithParam("--ops")
}
seen := make(map[string]bool)
seenPos := make(map[int32]bool)
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].id is required and cannot be empty", i).WithParam("--ops")
}
if op.Position <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].position must be a positive integer", i).WithParam("--ops")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --ops", op.ID).WithParam("--ops")
}
if seenPos[op.Position] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate position %d in --ops", op.Position).WithParam("--ops")
}
seen[op.ID] = true
seenPos[op.Position] = true
}
return ops, nil
}
// fetchObjectives fetches all objectives in a cycle.
func fetchObjectives(ctx context.Context, runtime *common.RuntimeContext, cycleID string) ([]Objective, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var objectives []Objective
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch objectives")
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var obj Objective
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
objectives = append(objectives, obj)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
}
// Sort objectives by position
sort.Slice(objectives, func(i, j int) bool {
pi := int32(0)
if objectives[i].Position != nil {
pi = *objectives[i].Position
}
pj := int32(0)
if objectives[j].Position != nil {
pj = *objectives[j].Position
}
return pi < pj
})
return objectives, nil
}
// fetchKeyResults fetches all key results for an objective.
func fetchKeyResults(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) ([]KeyResult, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch key results")
}
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)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
}
// Sort key results by position
sort.Slice(keyResults, func(i, j int) bool {
pi := int32(0)
if keyResults[i].Position != nil {
pi = *keyResults[i].Position
}
pj := int32(0)
if keyResults[j].Position != nil {
pj = *keyResults[j].Position
}
return pi < pj
})
return keyResults, nil
}
// buildReorderedIDs builds the complete ordered ID list from current items and reorder ops.
// Positions are treated as 1-indexed placement keys stored in a map (safe for large values).
// Items are first placed at user-specified positions, remaining items fill empty slots
// in original order starting from position 1, and final output is sorted by position.
func buildReorderedIDs[T reorderItem](items []T, ops []reorderOp, total int) ([]string, error) {
// Create a map of ID to current position
idToPos := make(map[string]int)
for i, item := range items {
idToPos[item.GetID()] = i
}
// Validate all ops IDs exist
for _, op := range ops {
if _, ok := idToPos[op.ID]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "id %q not found in current list", op.ID).WithParam("--ops")
}
}
// Use map to store position -> ID (1-indexed, safe for large position values)
posToID := make(map[int]string)
used := make(map[string]bool)
for _, op := range ops {
posToID[int(op.Position)] = op.ID
used[op.ID] = true
}
// Collect unused items in original order
var unused []string
for _, item := range items {
id := item.GetID()
if !used[id] {
unused = append(unused, id)
}
}
// Fill empty slots starting from position 1, in original order
unusedIdx := 0
for pos := 1; unusedIdx < len(unused); pos++ {
if _, occupied := posToID[pos]; !occupied {
posToID[pos] = unused[unusedIdx]
unusedIdx++
}
}
// Collect all positions, sort them, and build result in position order
positions := make([]int, 0, len(posToID))
for pos := range posToID {
positions = append(positions, pos)
}
sort.Ints(positions)
result := make([]string, 0, len(positions))
for _, pos := range positions {
result = append(result, posToID[pos])
}
return result, nil
}
// GetID implements the interface for Objective.
func (o Objective) GetID() string { return o.ID }
// GetID implements the interface for KeyResult.
func (k KeyResult) GetID() string { return k.ID }
// OKRReorder adjusts the position of objectives or key results.
var OKRReorder = common.Shortcut{
Service: "okr",
Command: "+reorder",
Description: "Adjust the position (order) of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to reorder: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "cycle-id", Desc: "OKR cycle ID (int64), Required."},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "ops", Desc: "JSON array of reorder operations: [{\"id\":\"...\",\"position\":1}], Required.", Input: []string{common.File, common.Stdin}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if strings.TrimSpace(level) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if strings.TrimSpace(cycleID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
}
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")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
opsStr := runtime.Str("ops")
if strings.TrimSpace(opsStr) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops is required").WithParam("--ops")
}
if err := common.RejectDangerousCharsTyped("--ops", opsStr); err != nil {
return err
}
if _, err := parseReorderOps(opsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseReorderOps(runtime.Str("ops"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to determine current order")
// Then reorder
reorderParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body with placeholder IDs
objectiveIDs := make([]string, 0, len(ops))
for _, op := range ops {
objectiveIDs = append(objectiveIDs, op.ID)
}
reorderBody := map[string]interface{}{
"objective_ids": objectiveIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update objective positions (full list sent, not just changes)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to determine current order")
reorderParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body with placeholder IDs
keyResultIDs := make([]string, 0, len(ops))
for _, op := range ops {
keyResultIDs = append(keyResultIDs, op.ID)
}
reorderBody := map[string]interface{}{
"key_result_ids": keyResultIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update key result positions (full list sent, not just changes)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseReorderOps(runtime.Str("ops"))
if err != nil {
return err
}
var reorderedIDs []string
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
reorderedIDs, err = buildReorderedIDs(objectives, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"cycle_id": cycleID,
}
body := map[string]interface{}{
"objective_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective positions")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
reorderedIDs, err = buildReorderedIDs(keyResults, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"objective_id": objectiveID,
}
body := map[string]interface{}{
"key_result_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result positions")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"ordered": reorderedIDs,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully reordered %d %s(s)\n", total, level)
fmt.Fprintln(w, "New order:")
for i, id := range reorderedIDs {
fmt.Fprintf(w, " Position %d: %s\n", i+1, id)
}
})
return nil
},
}

View File

@@ -0,0 +1,712 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// testReorderItem implements reorderItem for testing.
type testReorderItem struct {
id string
}
func (t testReorderItem) GetID() string { return t.id }
func reorderTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-reorder",
AppSecret: "secret-okr-reorder",
Brand: core.BrandFeishu,
}
}
func runReorderShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRReorder.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestReorderValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestReorderValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "invalid",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestReorderValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingOps(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "ops") {
t.Fatalf("expected --ops required error, got: %v", err)
}
}
func TestReorderValidate_InvalidOpsJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --ops JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_EmptyOpsArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "[]",
})
if err == nil {
t.Fatal("expected error for empty --ops array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_DuplicateID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"1","position":2}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
func TestReorderValidate_DuplicatePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"2","position":1}]`,
})
if err == nil {
t.Fatal("expected error for duplicate position in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate position") {
t.Fatalf("expected error to mention duplicate position, got: %v", err)
}
}
func TestReorderValidate_NegativePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":0}]`,
})
if err == nil {
t.Fatal("expected error for position <= 0")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
// --- DryRun tests ---
func TestReorderDryRun_Objectives(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_position") {
t.Fatalf("dry-run output should contain position update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_ids") {
t.Fatalf("dry-run output should contain objective_ids in body, got: %s", output)
}
}
func TestReorderDryRun_KeyResults(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_position") {
t.Fatalf("dry-run output should contain key_results position update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_ids") {
t.Fatalf("dry-run output should contain key_result_ids in body, got: %s", output)
}
}
// --- Execute tests ---
func TestReorderExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "3", "position": 3, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "3", "position": 1},
map[string]interface{}{"id": "1", "position": 2},
map[string]interface{}{"id": "2", "position": 3},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"3","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 3 {
t.Fatalf("expected 3 items in ordered list, got %d", len(ordered))
}
// First should be 3, then 1, then 2
if ordered[0] != "3" || ordered[1] != "1" || ordered[2] != "2" {
t.Fatalf("expected ordered [3,1,2], got %v", ordered)
}
}
func TestReorderExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch key results
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "position": 1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "position": 2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr2", "position": 1},
map[string]interface{}{"id": "kr1", "position": 2},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"kr2","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 2 {
t.Fatalf("expected 2 items in ordered list, got %d", len(ordered))
}
if ordered[0] != "kr2" || ordered[1] != "kr1" {
t.Fatalf("expected ordered [kr2,kr1], got %v", ordered)
}
}
func TestReorderExecute_PositionOutOfRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives (only 2 items)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
ids, ok := data["objective_ids"].([]interface{})
if !ok || len(ids) != 2 {
return false
}
// position 5 should be clamped to position 2 (last), so order is [2, 1]
return ids[0] == "2" && ids[1] == "1"
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":5}]`, // position 5 exceeds total of 2, should clamp to last
})
if err != nil {
t.Fatalf("unexpected error for out-of-range position (should clamp): %v", err)
}
}
func TestReorderExecute_IDNotFound(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"999","position":1}]`, // ID 999 doesn't exist
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseReorderOps_Valid(t *testing.T) {
t.Parallel()
ops, err := parseReorderOps(`[{"id":"1","position":2},{"id":"2","position":1}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || ops[0].Position != 2 {
t.Fatalf("expected op[0] = {1,2}, got %+v", ops[0])
}
if ops[1].ID != "2" || ops[1].Position != 1 {
t.Fatalf("expected op[1] = {2,1}, got %+v", ops[1])
}
}
func TestBuildReorderedIDs(t *testing.T) {
t.Parallel()
items := []Objective{
{ID: "1"},
{ID: "2"},
{ID: "3"},
{ID: "4"},
}
ops := []reorderOp{
{ID: "4", Position: 1},
{ID: "2", Position: 3},
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 4 at pos1, 1 at pos2 (unchanged), 2 at pos3, 3 at pos4
expected := []string{"4", "1", "2", "3"}
if len(result) != len(expected) {
t.Fatalf("expected %d items, got %d", len(expected), len(result))
}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_SingleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"},
}
ops := []reorderOp{
{ID: "1", Position: 99}, // position 99 exceeds total of 4, should clamp to last
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 4 at pos3, 1 at pos4 (clamped)
expected := []string{"2", "3", "4", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MultipleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "1", Position: 10}, // position 10 exceeds total of 5
{ID: "2", Position: 20}, // position 20 exceeds total of 5
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 3 at pos1, 4 at pos2, 5 at pos3, 1 at pos4 (clamped, pos10 < pos20), 2 at pos5 (clamped)
expected := []string{"3", "4", "5", "1", "2"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MixedClamp(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "5", Position: 1}, // normal position
{ID: "1", Position: 99}, // clamped to end
{ID: "2", Position: 50}, // clamped to end, but position 50 < 99, so comes before 1
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 5 at pos1, 3 at pos2, 4 at pos3, 2 at pos4 (clamped pos50), 1 at pos5 (clamped pos99)
expected := []string{"5", "3", "4", "2", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_LargePositionSafe(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"},
}
// Very large position should not cause memory issues with map-based implementation
ops := []reorderOp{
{ID: "1", Position: 100000000}, // 10^8, would be dangerous with slice
}
result, err := buildReorderedIDs(items, ops, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 1 at pos3 (clamped to end)
expected := []string{"2", "3", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}

490
shortcuts/okr/okr_weight.go Normal file
View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// weightItem is the interface for items that have ID and weight.
type weightItem interface {
GetID() string
GetWeight() float64
}
// weightOp represents a single weight assignment.
type weightOp struct {
ID string `json:"id"`
Weight float64 `json:"weight"`
}
// parseWeightOps parses and validates the --weights JSON array.
func parseWeightOps(weightsStr string) ([]weightOp, error) {
var ops []weightOp
if err := json.Unmarshal([]byte(weightsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must be valid JSON array: %s", err).WithParam("--weights").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must contain at least one weight assignment").WithParam("--weights")
}
seen := make(map[string]bool)
var sum float64
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].id is required and cannot be empty", i).WithParam("--weights")
}
if op.Weight < 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be non-negative", i).WithParam("--weights")
}
if op.Weight > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be <= 1", i).WithParam("--weights")
}
// Check for at most 3 decimal places
if math.Round(op.Weight*1000)/1000 != op.Weight {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must have at most 3 decimal places", i).WithParam("--weights")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --weights", op.ID).WithParam("--weights")
}
seen[op.ID] = true
sum += op.Weight
}
// Sum must be <= 1
if sum > 1+1e-9 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "sum of weights must be <= 1, got %.6f", sum).WithParam("--weights")
}
return ops, nil
}
// formatWeight formats a fixed-point weight value as a json.Number with exactly 3 decimal places.
// This ensures precise JSON serialization and avoids float64 precision issues.
func formatWeight(fp int64) json.Number {
return json.Number(fmt.Sprintf("%d.%03d", fp/1000, fp%1000))
}
// normalizeWeights normalizes weights using fixed-point arithmetic (×1000).
// - Specified weights are used as-is (already validated to 3 decimal places).
// - Remaining weight (1 - sum_specified) is distributed to unspecified items
// proportionally based on their original weights.
// - Fixed-point arithmetic ensures exact sum = 1, with residual added to the last item.
// - Weights are returned as json.Number to avoid float64 precision issues in JSON serialization.
func normalizeWeights[T weightItem](
items []T,
ops []weightOp,
) ([]map[string]interface{}, error) {
const scale = 1000 // fixed-point scale for 3 decimal places
// Build map of specified weights (as fixed-point integers)
specified := make(map[string]int64)
var specifiedSum int64
for _, op := range ops {
fp := int64(math.Round(op.Weight * scale))
specified[op.ID] = fp
specifiedSum += fp
}
// Calculate remaining weight to distribute (as fixed-point)
remaining := scale - specifiedSum
if remaining < 0 {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "weight calculation error: remaining weight is negative")
}
// Collect unspecified items and their original weights
type itemWithWeight struct {
item T
fp int64 // original weight as fixed-point
}
var unspecified []itemWithWeight
var originalUnspecifiedSum int64
for _, item := range items {
id := item.GetID()
if _, ok := specified[id]; ok {
continue
}
origWeight := item.GetWeight()
if origWeight < 0 {
origWeight = 0
}
fp := int64(math.Round(origWeight * scale))
unspecified = append(unspecified, itemWithWeight{item: item, fp: fp})
originalUnspecifiedSum += fp
}
// Distribute remaining weight proportionally
result := make([]map[string]interface{}, 0, len(items))
var resultSum int64
// First add specified items in original order
for _, item := range items {
id := item.GetID()
if fp, ok := specified[id]; ok {
result = append(result, map[string]interface{}{
"id": id,
"weight": formatWeight(fp),
})
resultSum += fp
}
}
// Then distribute to unspecified items
if len(unspecified) > 0 && remaining > 0 {
if originalUnspecifiedSum == 0 {
// All original weights are zero, distribute evenly
perItem := remaining / int64(len(unspecified))
residual := remaining - perItem*int64(len(unspecified))
for i, uw := range unspecified {
fp := perItem
// Add residual to the last unspecified item
if i == len(unspecified)-1 {
fp += residual
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
} else {
// Distribute proportionally based on original weights
var distributed int64
for i, uw := range unspecified {
var fp int64
if i == len(unspecified)-1 {
// Last item gets the remainder to ensure exact sum
fp = remaining - distributed
} else {
// Proportional distribution
fp = int64(float64(remaining) * float64(uw.fp) / float64(originalUnspecifiedSum))
distributed += fp
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
}
} else if remaining > 0 {
// All items were specified, add residual to the last item
if len(result) > 0 {
lastIdx := len(result) - 1
// Parse current weight as fixed-point and add residual
var lastFP int64
if lastWeight, ok := result[lastIdx]["weight"].(json.Number); ok {
if f, err := lastWeight.Float64(); err == nil {
lastFP = int64(math.Round(f * scale))
}
}
result[lastIdx]["weight"] = formatWeight(lastFP + remaining)
resultSum += remaining
}
}
// Verify sum is exactly 1.0
if resultSum != scale {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"weight normalization error: sum is %.6f, expected 1.0", float64(resultSum)/scale)
}
return result, nil
}
// GetWeight implements the interface for Objective.
func (o Objective) GetWeight() float64 {
if o.Weight == nil {
return 0
}
return *o.Weight
}
// GetWeight implements the interface for KeyResult.
func (k KeyResult) GetWeight() float64 {
if k.Weight == nil {
return 0
}
return *k.Weight
}
// OKRWeight adjusts the weight of objectives or key results.
var OKRWeight = common.Shortcut{
Service: "okr",
Command: "+weight",
Description: "Adjust the weight of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to adjust: objective | key-result", Enum: []string{"objective", "key-result"}, Required: true},
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "weights", Desc: "JSON array of weight assignments: [{\"id\":\"...\",\"weight\":0.5}]", Input: []string{common.File, common.Stdin}, Required: true},
},
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")
}
cycleID := runtime.Str("cycle-id")
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")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
weightsStr := runtime.Str("weights")
if err := common.RejectDangerousCharsTyped("--weights", weightsStr); err != nil {
return err
}
if _, err := parseWeightOps(weightsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseWeightOps(runtime.Str("weights"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to get current weights for normalization")
// Then update weights
weightParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body
objectiveWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"objective_weights": objectiveWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update objective weights (full list sent after normalization)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to get current weights for normalization")
weightParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body
keyResultWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update key result weights (full list sent after normalization)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseWeightOps(runtime.Str("weights"))
if err != nil {
return err
}
var normalizedWeights []map[string]interface{}
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
// Validate all specified IDs exist
objIDs := make(map[string]bool)
for _, obj := range objectives {
objIDs[obj.ID] = true
}
for _, op := range ops {
if !objIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "objective id %q not found in cycle", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(objectives, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, obj := range objectives {
if obj.Position != nil {
posMap[obj.ID] = *obj.Position
}
}
// Submit weight update
params := map[string]interface{}{
"cycle_id": cycleID,
}
objectiveWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(objectiveWeights, func(i, j int) bool {
idI := objectiveWeights[i]["objective_id"].(string)
idJ := objectiveWeights[j]["objective_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"objective_weights": objectiveWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective weights")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
// Validate all specified IDs exist
krIDs := make(map[string]bool)
for _, kr := range keyResults {
krIDs[kr.ID] = true
}
for _, op := range ops {
if !krIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "key_result id %q not found in objective", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(keyResults, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, kr := range keyResults {
if kr.Position != nil {
posMap[kr.ID] = *kr.Position
}
}
// Submit weight update
params := map[string]interface{}{
"objective_id": objectiveID,
}
keyResultWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(keyResultWeights, func(i, j int) bool {
idI := keyResultWeights[i]["key_result_id"].(string)
idJ := keyResultWeights[j]["key_result_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result weights")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"weights": normalizedWeights,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully updated weights for %d %s(s)\n", total, level)
fmt.Fprintln(w, "Weights:")
for _, weightEntry := range normalizedWeights {
fmt.Fprintf(w, " %s: %v\n", weightEntry["id"], weightEntry["weight"])
}
})
return nil
},
}

View File

@@ -0,0 +1,747 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"math"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// getWeightFloat extracts a float64 weight from either float64 or json.Number.
func getWeightFloat(v interface{}) float64 {
switch val := v.(type) {
case float64:
return val
case json.Number:
f, _ := val.Float64()
return f
default:
return 0
}
}
func weightTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return &core.CliConfig{
AppID: "test-okr-weight",
AppSecret: "secret-okr-weight",
Brand: core.BrandFeishu,
}
}
func runWeightShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRWeight.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestWeightValidate_MissingLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestWeightValidate_InvalidLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "invalid",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingCycleID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestWeightValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingWeights(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "weights") {
t.Fatalf("expected --weights required error, got: %v", err)
}
}
func TestWeightValidate_InvalidWeightsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --weights JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_EmptyWeightsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "[]",
})
if err == nil {
t.Fatal("expected error for empty --weights array")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_NegativeWeight(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":-0.1}]`,
})
if err == nil {
t.Fatal("expected error for negative weight")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "non-negative") {
t.Fatalf("expected error to mention non-negative, got: %v", err)
}
}
func TestWeightValidate_WeightGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":1.5}]`,
})
if err == nil {
t.Fatal("expected error for weight > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_TooManyDecimalPlaces(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.1234}]`,
})
if err == nil {
t.Fatal("expected error for weight with more than 3 decimal places")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "3 decimal places") {
t.Fatalf("expected error to mention 3 decimal places, got: %v", err)
}
}
func TestWeightValidate_SumGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.6},{"id":"2","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for sum of weights > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "sum of weights") {
t.Fatalf("expected error to mention sum of weights, got: %v", err)
}
}
func TestWeightValidate_DuplicateID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.3},{"id":"1","weight":0.4}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --weights")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
// --- DryRun tests ---
func TestWeightDryRun_Objectives(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5},{"id":"2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_weight") {
t.Fatalf("dry-run output should contain weight update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_weights") {
t.Fatalf("dry-run output should contain objective_weights in body, got: %s", output)
}
}
func TestWeightDryRun_KeyResults(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5},{"id":"kr2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_weight") {
t.Fatalf("dry-run output should contain key_results weight update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_weights") {
t.Fatalf("dry-run output should contain key_result_weights in body, got: %s", output)
}
}
// --- Execute tests ---
func TestWeightExecute_Objectives_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
w2 := 0.5
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "weight": &w2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": 0.7},
map[string]interface{}{"id": "2", "weight": 0.3},
},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.7}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
if len(weights) != 2 {
t.Fatalf("expected 2 items in weights list, got %d", len(weights))
}
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_KeyResults_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch key results
w1 := 0.3
w2 := 0.3
w3 := 0.4
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "weight": &w1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "weight": &w2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr3", "weight": &w3, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_IDNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"999","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseWeightOps_Valid(t *testing.T) {
ops, err := parseWeightOps(`[{"id":"1","weight":0.3},{"id":"2","weight":0.7}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || math.Abs(ops[0].Weight-0.3) > 1e-9 {
t.Fatalf("expected op[0] = {1,0.3}, got %+v", ops[0])
}
if ops[1].ID != "2" || math.Abs(ops[1].Weight-0.7) > 1e-9 {
t.Fatalf("expected op[1] = {2,0.7}, got %+v", ops[1])
}
}
func TestNormalizeWeights_AllSpecified(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.3},
{ID: "2", Weight: 0.7},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
}
func TestNormalizeWeights_PartialSpecified_Proportional(t *testing.T) {
w1 := 0.5
w2 := 0.3
w3 := 0.2
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
{ID: "3", Weight: &w3},
}
ops := []weightOp{
{ID: "1", Weight: 0.4}, // Specify 0.4 for item 1
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// Item 1 should have weight 0.4
var item1Weight float64
for _, r := range result {
if r["id"] == "1" {
item1Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item1Weight-0.4) > 1e-9 {
t.Fatalf("expected item 1 weight = 0.4, got %.10f", item1Weight)
}
}
func TestNormalizeWeights_ZeroOriginalWeights(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.5},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// When original weights are zero, remaining should be distributed evenly
var item2Weight float64
for _, r := range result {
if r["id"] == "2" {
item2Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item2Weight-0.5) > 1e-9 {
t.Fatalf("expected item 2 weight = 0.5 (even distribution), got %.10f", item2Weight)
}
}

View File

@@ -18,5 +18,9 @@ func Shortcuts() []common.Shortcut {
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
OKRBatchCreate,
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
}
}

View File

@@ -4,15 +4,74 @@
package slides
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// slidesRateLimitMaxRetries is the number of automatic retries (beyond the
// initial request) when the API answers 99991400 "request trigger frequency
// limit". The slides batch paths (+create slide loop, placeholder image
// uploads) fire consecutive POSTs and are the dominant 99991400 producers
// in telemetry; a short backoff absorbs a transient burst without masking a
// genuinely saturated tenant.
slidesRateLimitMaxRetries = 2
)
// slidesRateLimitBaseDelay is the initial backoff delay; subsequent retries
// double it (1s, 2s). Mirrors the wiki +node-create lock-contention pattern
// but with a larger base because a frequency window takes longer to clear than
// a sub-second lock race. var (not const) only so tests can shrink it.
var slidesRateLimitBaseDelay = 1 * time.Second
// isRateLimitedErr reports whether err is a typed retryable rate-limit error
// (e.g. 99991400), as classified by errclass.BuildAPIError.
func isRateLimitedErr(err error) bool {
p, ok := errs.ProblemOf(err)
return ok && p.Subtype == errs.SubtypeRateLimit && p.Retryable
}
// retryOnRateLimit runs fn, retrying with exponential backoff (1s, 2s) when it
// returns a retryable rate-limit error. Any other outcome — success or a
// different error — is returned immediately. Progress is announced on errOut
// so a user watching a batch upload understands the pause.
func retryOnRateLimit(ctx context.Context, errOut io.Writer, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= slidesRateLimitMaxRetries; attempt++ {
if attempt > 0 {
delay := slidesRateLimitBaseDelay << uint(attempt-1)
// Report the actual code from the error: the retry predicate matches
// any retryable SubtypeRateLimit, not just 99991400.
code := 0
if p, ok := errs.ProblemOf(lastErr); ok {
code = p.Code
}
fmt.Fprintf(errOut, "Rate limited by the API (%d), retrying (attempt %d/%d) in %v...\n",
code, attempt, slidesRateLimitMaxRetries, delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
lastErr = fn()
if lastErr == nil || !isRateLimitedErr(lastErr) {
return lastErr
}
}
return lastErr
}
// presentationRef holds a parsed --presentation input.
//
// Slides shortcuts accept three input shapes:
@@ -125,8 +184,30 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
// around `=`); without it we'd silently leave such placeholders unrewritten.
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
// xmlEntityUnescaper reverses the five XML built-in entities in attribute
// values captured from raw slide XML. strings.Replacer scans left-to-right in
// a single pass, so "&amp;lt;" correctly yields "&lt;" (the leading "&amp;"
// is consumed first), matching XML unescape semantics.
var xmlEntityUnescaper = strings.NewReplacer(
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&apos;", "'",
"&amp;", "&",
)
// placeholderFilePath converts a raw <img src="@..."> capture into the local
// filesystem path it refers to. The capture comes from well-formed XML where
// a literal & must be written &amp; (the precheck enforces this), so the
// entities are decoded before the path touches Stat/upload. Filesystem paths
// containing & are therefore written as e.g. src="@./Q1&amp;Q2.png".
func placeholderFilePath(raw string) string {
return xmlEntityUnescaper.Replace(strings.TrimSpace(raw))
}
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
// referenced via <img src="@path"> in the given slide XML strings.
// referenced via <img src="@path"> in the given slide XML strings, with XML
// built-in entities decoded (see placeholderFilePath).
//
// Order is preserved (first occurrence wins) so dry-run / progress messages are
// stable across runs.
@@ -141,7 +222,7 @@ func extractImagePlaceholderPaths(slideXMLs []string) []string {
// so we filter it here. Treat as malformed XML and skip.
continue
}
path := strings.TrimSpace(m[2])
path := placeholderFilePath(m[2])
if path == "" || seen[path] {
continue
}
@@ -280,6 +361,48 @@ func ensureShapeHasContent(xmlFragment string) string {
return xmlFragment[:m[1]] + "<content/>" + afterOpen
}
// checkXMLWellFormed verifies that fragment parses as well-formed XML, using
// the same parser family as the backend (Go encoding/xml). Syntax only —
// element names and attributes are NOT checked against the SML schema, so
// anything passing here can still be rejected server-side for semantic
// reasons; conversely nothing rejected here could ever have succeeded, which
// keeps the false-positive risk at zero.
//
// The backend reports these failures as an opaque 3350001/4001000
// "invalid param" with no position info; catching them locally turns the
// dominant real-world causes (bare & in text, unclosed tags, attribute
// quoting) into actionable messages with a line number.
//
// An <?xml ?> declaration is rejected explicitly: the rendering backend does
// not accept processing instructions on slide fragments (rejects with
// "?xml not provide the implement"). encoding/xml surfaces it as a regular
// ProcInst token, so it needs its own check.
//
// Multiple top-level elements are deliberately allowed — insertion fragments
// may legitimately carry sibling elements.
func checkXMLWellFormed(fragment string) error {
dec := xml.NewDecoder(strings.NewReader(fragment))
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
var syn *xml.SyntaxError
if errors.As(err, &syn) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"XML not well-formed at line %d: %s (escape literal & as &amp; and < as &lt; in text)",
syn.Line, syn.Msg)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML not well-formed: %v", err)
}
if pi, ok := tok.(xml.ProcInst); ok && strings.EqualFold(pi.Target, "xml") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"XML must not contain an <?xml ?> declaration (the slides backend rejects it); remove it and start at the root element")
}
}
}
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
// XML by looking up each path in tokens. Paths missing from the map are left
// untouched (callers should ensure the map is complete).
@@ -294,7 +417,10 @@ func replaceImagePlaceholders(slideXML string, tokens map[string]string) string
// Mismatched quotes — see extractImagePlaceholderPaths.
return match
}
token, ok := tokens[strings.TrimSpace(path)]
// tokens is keyed by the decoded filesystem path (see
// extractImagePlaceholderPaths), while oldQuoted below must use the
// raw capture so the literal XML text is what gets replaced.
token, ok := tokens[placeholderFilePath(path)]
if !ok {
return match
}

View File

@@ -4,9 +4,15 @@
package slides
import (
"bytes"
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
)
func TestParsePresentationRef(t *testing.T) {
@@ -216,6 +222,15 @@ func TestExtractImagePlaceholderPaths(t *testing.T) {
in: []string{`<img src = "@./spaced.png" />`},
want: []string{"./spaced.png"},
},
{
// Regression: the well-formedness precheck forces a literal & in a
// filename to be written &amp; in the XML; the captured path must
// be entity-decoded before it reaches Stat/upload so the file is
// actually found on disk.
name: "decodes XML entities in path",
in: []string{`<img src="@./Q1&amp;Q2.png"/>`},
want: []string{"./Q1&Q2.png"},
},
}
for _, tt := range tests {
@@ -233,8 +248,9 @@ func TestReplaceImagePlaceholders(t *testing.T) {
t.Parallel()
tokens := map[string]string{
"./pic.png": "tok_abc",
"./b.png": "tok_b",
"./pic.png": "tok_abc",
"./b.png": "tok_b",
"./Q1&Q2.png": "tok_amp", // keyed by decoded filesystem path
}
tests := []struct {
@@ -280,6 +296,13 @@ func TestReplaceImagePlaceholders(t *testing.T) {
in: `<img src = "@./pic.png" topLeftX="10"/>`,
want: `<img src = "tok_abc" topLeftX="10"/>`,
},
{
// Regression: tokens are keyed by the decoded filesystem path, but
// the literal XML text (with &amp;) is what must be rewritten.
name: "decodes XML entities when looking up token",
in: `<img src="@./Q1&amp;Q2.png" topLeftX="10"/>`,
want: `<img src="tok_amp" topLeftX="10"/>`,
},
}
for _, tt := range tests {
@@ -413,3 +436,152 @@ func TestEnsureXMLRootID(t *testing.T) {
})
}
}
func TestCheckXMLWellFormed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
wantErr string
}{
{name: "simple element", in: `<shape type="rect"><content/></shape>`},
{name: "nested with attributes", in: `<slide><shape type="text"><content><p>hi</p></content></shape></slide>`},
// Insertion fragments may carry sibling top-level elements; the decoder
// must not enforce a single document element.
{name: "multiple top-level elements", in: `<p>a</p><p>b</p>`},
{name: "escaped entities", in: `<p>A &amp; B &lt;tag&gt; &quot;q&quot;</p>`},
{name: "CDATA with raw ampersand", in: `<p><![CDATA[a & b < c]]></p>`},
{name: "comment", in: `<!-- note --><shape/>`},
{name: "img placeholder attr", in: `<img src="@./local.png" width="100"/>`},
{name: "unicode text", in: `<p>项目汇报 🎯</p>`},
// Top CLI-path failure cause in engine logs: bare & in text.
{name: "bare ampersand", in: `<p>Q & A</p>`, wantErr: "line 1"},
{name: "bare ampersand multiline", in: "<slide>\n<p>R&D</p>\n</slide>", wantErr: "line 2"},
{name: "unclosed tag", in: `<shape><content></shape>`, wantErr: "not well-formed"},
{name: "unquoted attribute", in: `<shape type=rect/>`, wantErr: "not well-formed"},
{name: "stray closing tag", in: `<p>hi</p></div>`, wantErr: "not well-formed"},
{name: "undefined entity", in: `<p>a&nbsp;b</p>`, wantErr: "not well-formed"},
// nodeserver rejects processing instructions ("?xml not provide the
// implement"); reject the declaration locally regardless of position.
{name: "xml declaration", in: `<?xml version="1.0"?><shape/>`, wantErr: "declaration"},
{name: "xml declaration with encoding", in: `<?xml version="1.0" encoding="UTF-8"?><slide/>`, wantErr: "declaration"},
{name: "uppercase xml declaration", in: `<?XML version="1.0"?><shape/>`, wantErr: "declaration"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := checkXMLWellFormed(tt.in)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
return
}
if err == nil {
t.Fatalf("want error containing %q, got nil", tt.wantErr)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("want SubtypeInvalidArgument, got %v", ve.Subtype)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("want error containing %q, got %q", tt.wantErr, err.Error())
}
})
}
}
// TestRetryOnRateLimit verifies the 99991400 backoff helper: retryable
// rate-limit errors are retried with backoff, anything else returns
// immediately, and exhaustion surfaces the last rate-limit error.
//
// Not parallel: shrinks the package-level slidesRateLimitBaseDelay.
func TestRetryOnRateLimit(t *testing.T) {
restore := slidesRateLimitBaseDelay
slidesRateLimitBaseDelay = time.Millisecond
t.Cleanup(func() { slidesRateLimitBaseDelay = restore })
rateLimitErr := func() error {
return errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithRetryable()
}
t.Run("success without retry", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
return nil
})
if err != nil || calls != 1 {
t.Fatalf("err=%v calls=%d, want nil/1", err, calls)
}
if errOut.Len() != 0 {
t.Fatalf("no retry message expected, got: %s", errOut.String())
}
})
t.Run("succeeds after transient rate limit", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
if calls <= 2 {
return rateLimitErr()
}
return nil
})
if err != nil || calls != 3 {
t.Fatalf("err=%v calls=%d, want nil/3", err, calls)
}
if !strings.Contains(errOut.String(), "retrying") {
t.Fatalf("expected retry announcement, got: %s", errOut.String())
}
})
t.Run("exhaustion returns last rate-limit error", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
return rateLimitErr()
})
if err == nil || !isRateLimitedErr(err) {
t.Fatalf("want rate-limit error after exhaustion, got: %v", err)
}
if calls != slidesRateLimitMaxRetries+1 {
t.Fatalf("calls=%d, want %d", calls, slidesRateLimitMaxRetries+1)
}
})
t.Run("non-rate-limit error returns immediately", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
boom := errs.NewAPIError(errs.SubtypeNotFound, "not found")
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
return boom
})
if !errors.Is(err, boom) || calls != 1 {
t.Fatalf("err=%v calls=%d, want boom/1", err, calls)
}
})
t.Run("cancelled context aborts the backoff wait", func(t *testing.T) {
var errOut bytes.Buffer
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := retryOnRateLimit(ctx, &errOut, func() error {
return rateLimitErr()
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("want context.Canceled, got: %v", err)
}
})
}

View File

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

View File

@@ -50,6 +50,15 @@ var SlidesCreate = common.Shortcut{
if len(slides) > maxSlidesPerCreate {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate).WithParam("--slides")
}
// Well-formedness precheck before any API call: a syntax error in
// slide N would otherwise create the presentation and then fail on
// the slide POST with an opaque backend "invalid param", leaving a
// partially-built deck behind.
for i, slideXML := range slides {
if err := checkXMLWellFormed(slideXML); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides[%d]: %v", i, err).WithParam("--slides").WithCause(err)
}
}
// Validate placeholder paths up front so we don't create a presentation
// only to fail mid-way on a missing local file.
for _, path := range extractImagePlaceholderPaths(slides) {
@@ -183,14 +192,22 @@ var SlidesCreate = common.Shortcut{
var slideIDs []string
for i, slideXML := range slides {
slideData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
var slideData map[string]interface{}
// Consecutive slide POSTs are the main 99991400 producer in
// telemetry; absorb the per-second frequency window with a
// short backoff instead of aborting the whole batch.
err := retryOnRateLimit(ctx, runtime.IO().ErrOut, func() error {
var callErr error
slideData, callErr = runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
return callErr
})
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
}

View File

@@ -10,6 +10,7 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -392,7 +393,10 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
},
})
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<bad-xml>"]`
// The second slide is well-formed XML (so it passes the local precheck)
// but uses an element the backend rejects — partial failure must come from
// the API layer, not validation.
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide><audio src=\"x\"/></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Partial",
@@ -918,3 +922,93 @@ func TestSlidesCreateWithPlaceholdersDryRun(t *testing.T) {
t.Fatalf("dry-run header should describe upload count, got: %s", out)
}
}
// TestSlidesCreateRejectsMalformedSlideXML verifies the well-formedness
// precheck fires before any API call — no presentation should be created when
// a slide fragment has a syntax error, so no httpmock stubs are registered.
func TestSlidesCreateRejectsMalformedSlideXML(t *testing.T) {
t.Parallel()
tests := []struct {
name string
slides string
wantErr string
}{
{"bare ampersand", `["<slide><p>Q & A</p></slide>"]`, "--slides[0]"},
{"unclosed tag", `["<slide><p>ok</p></slide>","<slide><shape></slide>"]`, "--slides[1]"},
{"xml declaration", `["<?xml version=\"1.0\"?><slide/>"]`, "declaration"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "precheck",
"--slides", tt.slides,
"--as", "user",
})
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.name)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
if !strings.Contains(err.Error(), "well-formed") && !strings.Contains(err.Error(), "declaration") {
t.Fatalf("error should explain the XML problem, got %q", err.Error())
}
})
}
}
// TestSlidesCreateRetriesSlideRateLimit verifies the +create slide loop
// retries a 99991400 "request trigger frequency limit" slide POST with
// backoff instead of aborting the batch (one-shot stubs: first slide POST
// answers 99991400, the second answers success — both must be consumed).
//
// Not parallel: shrinks the package-level slidesRateLimitBaseDelay.
func TestSlidesCreateRetriesSlideRateLimit(t *testing.T) {
restore := slidesRateLimitBaseDelay
slidesRateLimitBaseDelay = time.Millisecond
t.Cleanup(func() { slidesRateLimitBaseDelay = restore })
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"xml_presentation_id": "pres_rl", "revision_id": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_rl/slide",
Status: 400,
Body: map[string]interface{}{"code": 99991400, "msg": "request trigger frequency limit"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_rl/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s1", "revision_id": 2}},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "RL test",
"--slides", `["<slide><data/></slide>"]`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error (rate limit should have been retried): %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["slides_added"] != float64(1) {
t.Fatalf("slides_added = %v, want 1", data["slides_added"])
}
if !strings.Contains(stderr.String(), "retrying") {
t.Fatalf("expected retry announcement on stderr, got: %s", stderr.String())
}
}

View File

@@ -128,13 +128,26 @@ func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string
fileName, common.FormatSize(fileSize))
}
parent := presentationID
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: slidesMediaParentType,
ParentNode: &parent,
var fileToken string
// upload_all is rate-limited per second; consecutive placeholder uploads
// from +create (and rapid repeated +media-upload calls) can hit 99991400.
// A failed rate-limited attempt creates nothing server-side, and each
// attempt re-opens the file from FilePath, so the retry is safe.
// The Typed variant is required here: retryOnRateLimit matches on the
// typed subtype, and slides error wrapping (appendSlidesProgressHint)
// already expects typed errs.* envelopes.
err := retryOnRateLimit(runtime.Ctx(), runtime.IO().ErrOut, func() error {
var callErr error
fileToken, callErr = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: slidesMediaParentType,
ParentNode: &parent,
})
return callErr
})
return fileToken, err
}
// appendSlidesUploadDryRun renders the upload_all step for a single file.

View File

@@ -12,6 +12,7 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -367,3 +368,49 @@ func readAll(t *testing.T, r interface {
}
return buf.Bytes()
}
// TestSlidesMediaUploadRetriesRateLimit verifies uploadSlidesMedia retries a
// 99991400 "request trigger frequency limit" upload_all response with backoff
// (one-shot stubs: the rate-limited response is consumed first, then the
// success response) and still returns the file_token.
//
// Not parallel: uses os.Chdir and shrinks slidesRateLimitBaseDelay.
func TestSlidesMediaUploadRetriesRateLimit(t *testing.T) {
restore := slidesRateLimitBaseDelay
slidesRateLimitBaseDelay = time.Millisecond
t.Cleanup(func() { slidesRateLimitBaseDelay = restore })
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("rl.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Status: 400,
Body: map[string]interface{}{"code": 99991400, "msg": "request trigger frequency limit"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_rl"}},
})
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "rl.png",
"--presentation", "pres_rl_upload",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error (rate limit should have been retried): %v", err)
}
data := decodeShortcutData(t, stdout)
if data["file_token"] != "tok_rl" {
t.Fatalf("file_token = %v, want tok_rl", data["file_token"])
}
}

View File

@@ -34,6 +34,9 @@ const maxReplaceParts = 200
// it triggers 3350001.
// 4. On 3350001 errors it enriches the hint with context-specific guidance
// so AI agents can self-correct.
// 5. It rejects non-well-formed replacement/insertion XML before any API
// call, with a line number and escaping hint — the backend reports these
// only as an opaque 3350001/4001000 "invalid param".
//
// `str_replace` is intentionally NOT exposed: product direction is that
// slide edits go through structural (block-level) operations only. The backend
@@ -278,6 +281,8 @@ func enrichSlidesReplaceError(err error) error {
// - size is within [1, 200]
// - action is one of the exposed actions (block_replace / block_insert)
// - per-action required fields are present
// - replacement / insertion fragments are well-formed XML (syntax only;
// see checkXMLWellFormed)
func validateReplaceParts(parts []replacePart) error {
if len(parts) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item").WithParam("--parts")
@@ -294,10 +299,16 @@ func validateReplaceParts(parts []replacePart) error {
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i).WithParam("--parts")
}
if err := checkXMLWellFormed(*p.Replacement); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithParam("--parts").WithCause(err)
}
case "block_insert":
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i).WithParam("--parts")
}
if err := checkXMLWellFormed(*p.Insertion); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion: %v", i, err).WithParam("--parts").WithCause(err)
}
case "str_replace":
// Backend still accepts str_replace, but product decision is to
// force structural edits through the CLI. Block it up-front so

View File

@@ -731,3 +731,41 @@ func TestReplaceSlideValidationParam(t *testing.T) {
})
}
}
// TestReplaceSlideRejectsMalformedFragmentXML verifies the well-formedness
// precheck on replacement / insertion fragments fires at validation time,
// before wiki resolution or the replace POST.
func TestReplaceSlideRejectsMalformedFragmentXML(t *testing.T) {
t.Parallel()
tests := []struct {
name string
parts string
wantErr string
}{
{"replacement bare ampersand", `[{"action":"block_replace","block_id":"bUn","replacement":"<shape><content><p>R & D</p></content></shape>"}]`, "--parts[0].replacement"},
{"replacement unclosed tag", `[{"action":"block_replace","block_id":"bUn","replacement":"<shape><content></shape>"}]`, "--parts[0].replacement"},
{"insertion xml declaration", `[{"action":"block_insert","insertion":"<?xml version=\"1.0\"?><shape/>"}]`, "declaration"},
{"second part malformed", `[{"action":"block_insert","insertion":"<p>ok</p>"},{"action":"block_insert","insertion":"<p>Q & A</p>"}]`, "--parts[1].insertion"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{
"+replace-slide",
"--presentation", "pres_abc",
"--slide-id", "s",
"--parts", tt.parts,
"--as", "user",
})
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.name)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,537 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
// local files. The raw API returns Base64 image payloads; this shortcut keeps
// those payloads out of stdout so agents only see small file metadata.
var SlidesScreenshot = common.Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
{Name: "output-name", Desc: "file name stem for --content render output"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
renderMode := runtime.Changed("content")
if renderMode {
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
} else {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
return err
}
if !hasSlideScreenshotSelector(runtime) {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
}
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Changed("content") {
return dryRunRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)).
Body(body)
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("content") {
return executeRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
return err
}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)
query := larkcore.QueryParams{}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
if err != nil {
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
}
saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"xml_presentation_id": presentationID,
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
},
}
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
}
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
Body(map[string]interface{}{
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
})
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
}
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
return err
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name"))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
}
func normalizeSlideIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, v := range values {
s := strings.TrimSpace(v)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func normalizeSlideNumbers(values []int) ([]int, error) {
out := make([]int, 0, len(values))
seen := map[int]struct{}{}
for _, n := range values {
if n < 1 {
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out, nil
}
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
}
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
}
return outputDir, nil
}
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
return validateScreenshotOutputDir(runtime, outputDir)
}
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) {
items := common.GetSlice(data, "slide_images")
if len(items) == 0 {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
}
saved := make([]map[string]interface{}, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
}
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "")
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
}
saved = append(saved, item)
}
return saved, nil
}
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
item := common.GetMap(data, "slide_image")
if item == nil {
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
}
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide")
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
}
return []map[string]interface{}{saved}, nil
}
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
ext, label, err := slideScreenshotFormat(item)
if err != nil {
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
}
encoded := strings.TrimSpace(common.GetString(item, "data"))
if encoded == "" {
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
}
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
}
fileBase := strings.TrimSpace(outputName)
if fileBase == "" {
fileBase = slideID
}
if fileBase == "" {
fileBase = fallbackName
}
path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
if err != nil {
return nil, err
}
return map[string]interface{}{
"slide_id": slideID,
"slide_number": slideScreenshotInt(item, "slide_number"),
"format": label,
"path": path,
"size": len(imageBytes),
}, nil
}
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
presentationID = strings.TrimSpace(presentationID)
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
slideNumber := slideScreenshotInt(item, "slide_number")
if presentationID != "" {
switch {
case slideNumber > 0 && slideID != "":
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
case slideNumber > 0:
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
case slideID != "":
return fmt.Sprintf("%s_%s", presentationID, slideID)
}
}
if slideID != "" {
return slideID
}
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
return fmt.Sprintf("slide-%d", slideNumber)
}
return fmt.Sprintf("slide-%d", index+1)
}
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
format := slideScreenshotInt(item, "format")
switch format {
case 1:
return "png", "png", nil
case 2:
return "jpg", "jpeg", nil
default:
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
}
}
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
}
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
}
func slideScreenshotInt(item map[string]interface{}, key string) int {
n, ok := util.ToFloat64(item[key])
if !ok {
return 0
}
return int(n)
}
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
QueryParams: query,
}
if body != nil {
req.Body = body
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, errs.WrapInternal(err)
}
data, err := runtime.ClassifyAPIResponse(resp)
if err != nil {
return data, err
}
if data == nil {
data = map[string]interface{}{}
}
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
data["log_id"] = logID
}
return data, nil
}
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
if len(slideNumbers) == 0 {
return err
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
if p.Hint == "" {
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
}
return err
}
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func isSlidesScreenshotPassthroughError(err error) bool {
_, ok := errs.ProblemOf(err)
return ok
}
func summarizeScreenshotAPIData(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = summarizeScreenshotAPIData(val)
}
return out
case []interface{}:
out := make([]interface{}, 0, len(x))
for i, val := range x {
if i >= 20 {
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
break
}
out = append(out, summarizeScreenshotAPIData(val))
}
return out
case string:
if len(x) > 512 {
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
}
return x
default:
return x
}
}
func safeScreenshotFileBase(base string) string {
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
name = strings.Trim(name, "._-")
if name == "" {
name = "slide"
}
return name
}
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
base := safeScreenshotFileBase(fileBase)
for i := 0; i < 1000; i++ {
candidateBase := base
if i > 0 {
candidateBase = fmt.Sprintf("%s_%d", base, i+1)
}
path := filepath.Join(outputDir, candidateBase+"."+ext)
if _, err := runtime.FileIO().Stat(path); err == nil {
continue
} else if !isScreenshotFileNotExist(err) {
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err)
}
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
return "", common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(path)
if err != nil {
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err)
}
return resolvedPath, nil
}
path := filepath.Join(outputDir, base+"."+ext)
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path)
}
func isScreenshotFileNotExist(err error) bool {
return os.IsNotExist(err)
}

View File

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

View File

@@ -18,6 +18,7 @@ metadata:
## 快速决策
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。

View File

@@ -0,0 +1,168 @@
# 权限治理 Command Patterns
本文只提供 `permission_governance` workflow 的具体 `lark-cli` 命令样例。只有进入对应 state 且需要拼装命令时才读取本文;命令可用范围仍以 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) 的 `Command Map` 为准。
## 目录
- `目标解析`
- `目标发现`
- `事实读取`
- `写前确认与执行`
## 目标解析
```bash
lark-cli drive +inspect --url '<url>' --as user --format json
```
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`
## 目标发现
发现 Wiki space / node 下目标:
```bash
lark-cli wiki +node-list \
--space-id '<space_id>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
--as user --format json
```
解析返回时使用 `data.nodes`,不要读取顶层 `items``--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
发现 Drive folder 下目标:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200}' \
--as user --format json
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
--as user --format json
```
## 事实读取
读取 metadata
```bash
lark-cli drive metas batch_query \
--data '{"request_docs":[{"doc_token":"<token>","doc_type":"<type>"}],"with_url":true}' \
--as user --format json
```
读取 public permission
```bash
lark-cli drive permission.public get \
--params '{"token":"<token>","type":"<type>"}' \
--as user --format json
```
按需读取访问统计:
```bash
lark-cli drive file.statistics get \
--params '{"file_token":"<token>","file_type":"<type>"}' \
--as user --format json
```
按需读取最近访问记录:
```bash
lark-cli drive file.view_records list \
--params '{"file_token":"<token>","file_type":"<type>","page_size":50}' \
--as user --format json
```
## 写前确认与执行
patch 前检查 manage-public permission
```bash
lark-cli drive permission.members auth \
--params '{"token":"<token>","type":"<type>","action":"manage_public"}' \
--as user --format json
```
patch 前读取当前 schema
```bash
lark-cli schema drive.permission.public.patch --format json
```
只 patch 当前 schema 支持的字段;对 Wiki 目标,必须省略 schema 明确标注为 Wiki 不支持的字段。
显式确认后 patch public permission
```bash
lark-cli drive permission.public patch \
--params '{"token":"<token>","type":"<type>"}' \
--data '{"link_share_entity":"closed","external_access":false}' \
--as user --yes --format json
```
显式确认后申请访问权限:
```bash
lark-cli drive +apply-permission \
--token '<url>' \
--perm view --remark '<reason>' --as user --format json
lark-cli drive +apply-permission \
--token '<bare-token>' --type '<type>' \
--perm view --remark '<reason>' --as user --format json
```
owner 转移前读取当前 schema
```bash
lark-cli schema drive.permission.members.transfer_owner --format json
```
显式确认后转移 owner
```bash
lark-cli drive permission.members transfer_owner \
--params '{"token":"<token>","type":"<type>","need_notification":true,"remove_old_owner":false,"old_owner_perm":"full_access","stay_put":true}' \
--data '{"member_id":"<new_owner_open_id>","member_type":"openid"}' \
--as user --yes --format json
```
`member_type` 只能使用当前 schema 支持的值:`email``openid``userid``appid`。如果用户只给姓名,必须先解析为明确身份或要求用户补充;不要猜测 `member_id`。批量 owner 转移必须逐个目标顺序执行。
secure label 写前枚举可用标签:
```bash
lark-cli drive +secure-label-list \
--page-size 10 --lang zh \
--as user --format json
lark-cli drive +secure-label-list \
--page-size 10 --page-token '<PAGE_TOKEN>' --lang zh \
--as user --format json
```
当用户给出的是标签名称、密级文案或不确定的 label ID 时,必须先枚举并解析为 `label-id`;写入确认里展示目标标签名称和 ID。找不到唯一标签时停止并让用户选择不要猜测。
显式确认后更新 secure label
```bash
lark-cli drive +secure-label-update \
--token '<url>' \
--label-id '<label-id>' --as user --format json
lark-cli drive +secure-label-update \
--token '<bare-token>' --type '<type>' \
--label-id '<label-id>' --as user --format json
```

View File

@@ -0,0 +1,424 @@
# 权限治理输出模板
本文只提供 `permission_governance` workflow 的用户可见输出模板。默认先给简短摘要;只有用户要求完整表格、需要写入确认,或结果大到需要结构化展示时才读取本文。
## 目录
- `输出策略`
- `Semantic Rendering`
- `定位与治理动作`
- `单目标公开性判断`
- `多目标明确列表诊断`
- `审计摘要`
- `容器安全诊断报告摘要`
- `可操作风险清单`
- `治理选择交互`
- `权限设置清单`
- `访问复核清单`
- `整改 dry-run`
- `批量权限申请确认`
- `owner 转移确认`
- `确认请求`
- `最终摘要`
## 输出策略
- 单目标默认输出审计摘要。
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity``external_access_entity``external_access` 等底层字段名;只有用户要求 raw evidence、排障或完整清单 / artifact 场景才展示底层字段。
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
- 容器安全诊断里不要把 `external_access=true` / `external_access_entity=open` 简写成“高风险”或“外部泄露”;用户可见说法应为“允许对外分享,需 owner 复核;这不等于已经存在外部协作者”。
- 风险对象展示按规模渐进披露1-10 个全部展示11-30 个展示全部高优先级待复核对象,中 / 低优先级只做分组摘要31-100 个按高优先级待复核分组展示 Top 5 和数量100+ 个只展示分组统计和 Top 样例。
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读就只报告风险后结束。
- 完整风险清单是后续治理选择的输入Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`
- 写入前必须使用确认模板权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
## Semantic Rendering
面向用户的主结论优先渲染 `per_target_permission_assessment` 中的语义状态,并使用用户当前语言;底层字段名只在 raw evidence、排障或完整清单中保留。下表给出字段值到业务表达的标准映射其他语言应表达同等业务含义。
字段来源边界:下表同时覆盖官方 OpenAPI 语义和当前 / 未来 CLI schema。只有实际响应或当前 schema 返回的字段和值,才可渲染为确定状态;当前 installed CLI 未返回的字段(例如 `copy_entity``manage_collaborator_entity``external_access_entity`)或未出现的枚举值,只能在 raw response / schema 实际出现时使用,缺失时必须按 unknown / unsupported 处理,不要臆造。
| Raw field / value | Semantic State | 中文说法 | English phrasing |
|-------------------|----------------|----------|------------------|
| `link_share_entity=anyone_readable` | `link_access=public_readable` | 互联网上获得链接的任何人可阅读 | Anyone on the internet with the link can read |
| `link_share_entity=anyone_editable` | `link_access=public_editable` | 互联网上获得链接的任何人可编辑 | Anyone on the internet with the link can edit |
| `link_share_entity=partner_tenant_readable` | `link_access=partner_readable` | 关联组织内知道链接可读 | People in partner tenants with the link can read |
| `link_share_entity=partner_tenant_editable` | `link_access=partner_editable` | 关联组织内知道链接可编辑 | People in partner tenants with the link can edit |
| `link_share_entity=tenant_readable` | `link_access=tenant_readable` | 公司内知道链接可读 | People in the tenant with the link can read |
| `link_share_entity=tenant_editable` | `link_access=tenant_editable` | 公司内知道链接可编辑 | People in the tenant with the link can edit |
| link sharing empty / disabled | `link_access=closed` | 未开启链接分享 | Link sharing is disabled |
| `external_access_entity=open` or `external_access=true` | `external_sharing=open` | 允许分享到组织外;不等于已经存在外部协作者 | External sharing is open; this does not mean external collaborators already exist |
| `external_access_entity=allow_share_partner_tenant` | `external_sharing=partner_only` | 仅允许分享到关联组织 | Sharing is allowed only with partner tenants |
| `external_access_entity=closed` or `external_access=false` | `external_sharing=closed` | 当前不允许分享到组织外 | External sharing is disabled |
| `invite_external=true` | `external_invitation=enabled` | 当前允许邀请外部用户 | Inviting external users is enabled |
| `invite_external=false` | `external_invitation=disabled` | 当前不允许邀请外部用户 | Inviting external users is disabled |
| `share_entity=anyone` | `collaborator_org_scope=all_viewers_or_editors` | 所有可阅读或可编辑者可查看、添加、移除协作者 | All viewers or editors can view, add, and remove collaborators |
| `share_entity=same_tenant` | `collaborator_org_scope=tenant_viewers_or_editors` | 组织内可阅读或可编辑者可查看、添加、移除协作者 | Tenant viewers or editors can view, add, and remove collaborators |
| `manage_collaborator_entity=collaborator_can_view` | `collaborator_permission_scope=viewer` | 拥有可阅读权限的协作者可查看、添加、移除协作者 | Collaborators with view permission can view, add, and remove collaborators |
| `manage_collaborator_entity=collaborator_can_edit` | `collaborator_permission_scope=editor` | 拥有可编辑权限的协作者可查看、添加、移除协作者 | Collaborators with edit permission can view, add, and remove collaborators |
| `manage_collaborator_entity=collaborator_full_access` | `collaborator_permission_scope=full_access` | 拥有可管理权限的协作者可查看、添加、移除协作者 | Collaborators with full-access permission can view, add, and remove collaborators |
| `copy_entity=anyone_can_view` | `copy_scope=viewer` | 拥有可阅读权限的用户可复制内容 | Users with view permission can copy content |
| `copy_entity=anyone_can_edit` | `copy_scope=editor` | 拥有可编辑权限的用户可复制内容 | Users with edit permission can copy content |
| `copy_entity=only_full_access` | `copy_scope=full_access` | 仅拥有可管理权限的协作者可复制内容 | Only collaborators with full-access permission can copy content |
| `security_entity=anyone_can_view` | `security_scope=viewer` | 拥有可阅读权限的用户可创建副本、打印、下载 | Users with view permission can create copies, print, and download |
| `security_entity=anyone_can_edit` | `security_scope=editor` | 拥有可编辑权限的用户可创建副本、打印、下载 | Users with edit permission can create copies, print, and download |
| `security_entity=only_full_access` | `security_scope=full_access` | 仅拥有可管理权限的用户可创建副本、打印、下载 | Only users with full-access permission can create copies, print, and download |
| `comment_entity=anyone_can_view` | `comment_scope=viewer` | 拥有可阅读权限的用户可评论 | Users with view permission can comment |
| `comment_entity=anyone_can_edit` | `comment_scope=editor` | 拥有可编辑权限的用户可评论 | Users with edit permission can comment |
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
## 定位与治理动作
风险对象必须能让用户直接定位和处理:
- 摘要中的每个优先处理对象必须包含 `risk_id``path/title``URL``type`、owner、sec_label、风险原因、关键证据和建议动作。
- 完整清单、访问复核清单、整改 dry-run 和写入确认都必须包含 URL。缺少 URL 时,展示 token / node_token并说明 URL 未能获取。
- 同名文档、shortcut 或副本必须用 path + URL 区分;不要只输出 title。
- 完整风险清单中的每条记录必须有稳定 `risk_id`,格式为 `PG-001``PG-002``risk_id` 在同一次诊断和后续 dry-run / 确认 / 验证中保持不变。
- 即使摘要只展示 Top 样例,也必须给样例分配稳定 `risk_id`;不能输出无法选择的标题列表。
- 建议动作必须和风险类型绑定:互联网公开链接优先建议关闭链接分享或收紧为组织内;允许对外分享优先建议 owner 复核或关闭对外分享;缺少密级标签优先建议补齐密级;复制 / 下载 / 评论范围只在用户 policy 明确时建议收紧。
- 写入动作只能作为下一步选项或确认请求出现。不要在诊断摘要里暗示已经执行缩权。
## 单目标公开性判断
`intent=public_exposure_check``target_scope=single_resource` 时,使用此模板。默认渲染 `target_count=1``per_target_permission_assessment`,跟随用户当前语言,不直接展示底层字段名;用户要求 raw evidence 时,再追加字段证据。
中文模板:
```text
结论:<不是对外公开 / 存在互联网公开链接 / 允许对外分享>。
目标:<title>
URL<url-or-token-if-url-unavailable>
类型:<type>
当前链接访问范围:<render link_access>
对外分享:<render external_sharing>
外部邀请:<render external_invitation or omit if unknown because field is absent>
协作者管理(组织维度):<render collaborator_org_scope>
协作者管理(权限维度):<render collaborator_permission_scope or omit if unknown because field is absent>
复制内容:<render copy_scope or omit if unknown because field is absent>
创建副本 / 打印 / 下载:<render security_scope>
评论:<render comment_scope>
Wiki 继承限制:<render lock_state or omit if unknown because field is absent>
检查边界:<render check_scope>
```
English template:
```text
Conclusion: <Not publicly accessible on the internet / A public internet link is enabled / External sharing is enabled>.
Target: <title>
URL: <url-or-token-if-url-unavailable>
Type: <type>
Current link access: <render link_access>
External sharing: <render external_sharing>
External invitations: <render external_invitation or omit if unknown because field is absent>
Collaborator management by tenant: <render collaborator_org_scope>
Collaborator management by permission: <render collaborator_permission_scope or omit if unknown because field is absent>
Copy content: <render copy_scope or omit if unknown because field is absent>
Create copies / print / download: <render security_scope>
Comments: <render comment_scope>
Wiki inheritance lock: <render lock_state or omit if unknown because field is absent>
Check boundary: <render check_scope>
```
Raw evidence, only when requested:
```text
Evidence fields:
- link_share_entity=<value>
- external_access_entity=<value>
- external_access=<value>
- invite_external=<value>
- share_entity=<value>
- manage_collaborator_entity=<value>
- copy_entity=<value>
- security_entity=<value>
- comment_entity=<value>
- lock_switch=<value>
```
## 多目标明确列表诊断
`target_scope=explicit_list` 时,使用此模板。该场景不执行容器递归发现;对用户提供的每个 URL / token 逐个生成 `per_target_permission_assessment`,再按风险分组聚合。权限语义和单目标、容器诊断完全复用,不新增判断模型。
```text
已完成只读权限诊断,没有做任何权限修改。
一句话结论:<N> 个目标中,<risk_count> 个存在待复核权限风险;<internet_public_count> 个存在互联网公开链接候选,<external_access_count> 个允许对外分享,<unknown_count> 个无法完整判断。
覆盖情况:
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
逐目标结果1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
- <risk_id-or-item_id> <path-or-title> (<type>)
URL: <url-or-token-if-url-unavailable>
结论:<not_public / public_link_enabled / external_sharing_enabled / policy_review / unknown>
关键权限:<render link_access>; <render external_sharing>; <render security_scope>; <render comment_scope>
密级:<sec_label_name-or-missing-or-unknown>
待复核原因:<risk reason or none>
建议动作:<recommended action or no action>
分组摘要:
- 互联网公开链接候选:<count>;允许对外分享:<count>;公司内链接可访问 / 可编辑:<count>
- 复制 / 下载 / 打印 / 评论待策略确认:<count>;无法判断:<count and reason summary>
建议下一步:
- 处理明确的 <risk_id>,先生成只读 dry-run。
- 生成完整风险清单 artifact后续可按 `risk_id`、风险分组、URL 或 `selected=true` 选择治理范围;只看权限设置时改用 `权限设置清单`。
```
## 摘要清单展开规则
容器安全诊断的摘要必须兼顾可读性和可治理性。不要用固定 Top N 代替可处理清单。
| 风险对象数 | 摘要默认展示 | 必须提供的下一步 |
|------------|--------------|------------------|
| `0` | 只展示覆盖情况、未覆盖能力和剩余限制 | 如需更细审计,可生成权限设置清单 |
| `1-10` | 展示全部风险对象 | 可直接按 `risk_id` 生成 dry-run 或写入确认 |
| `11-30` | 展示全部高优先级待复核对象;中 / 低优先级做分组摘要 | 生成完整风险清单 artifact或按风险分组生成 dry-run |
| `31-100` | 每个高优先级待复核分组展示 Top 5附未展示数量 | 生成 Markdown / CSV / 飞书文档完整风险清单 |
| `100+` | 只展示分组统计、Top 样例和覆盖限制,不内联长表 | 强烈建议生成结构化风险清单后再选择治理范围 |
高优先级待复核对象包括:互联网公开链接、允许对外分享、允许对外分享且缺少 / 低于 policy 密级标签、公司内可编辑链接。协作者管理范围较宽默认归入中优先级待复核;只有用户 policy 明确要求严格协作者管理时才提升优先级。复制 / 下载 / 打印、评论范围在用户未提供明确 policy 时归入“待策略确认”,不要挤占高优先级清单。
摘要中的每个待复核对象必须包含 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、关键证据和建议动作。对同一底层文档的多个 Wiki 入口或 shortcut必须用 URL 区分;如果建议合并治理,在建议动作里说明它们指向同一底层对象。
## 审计摘要
```text
目标:<title> (<type>)
URL<url-or-token-if-url-unavailable>
结论:<合规 / 待确认风险 / 无法完整判断>
证据:
- link_share_entity=<value>
- external_access_entity=<value>
- external_access=<value>
- invite_external=<value>
- share_entity=<value>
- manage_collaborator_entity=<value>
- copy_entity=<value>
- security_entity=<value>
- comment_entity=<value>
- lock_switch=<value>
- sec_label_name=<value-or-missing>
限制:<unsupported_checks or none>
建议动作:<read-only next step or proposed remediation>
```
## 容器安全诊断报告摘要
```text
已完成只读安全诊断,没有做任何权限修改。
一句话结论:<未发现互联网公开链接 / 存在互联网公开链接候选风险><external_access_count> 个文档允许对外分享,<missing_label_count> 个文档缺少密级标签。建议优先复核 <top_priority_group_or_paths>。
覆盖情况:
- 当前身份可见目标:<visible_count>
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
- 读取失败 / 已删除 / 无权限:<failed_count>
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
风险分级:
- 高优先级待复核:<internet_public_count> 个互联网公开链接候选;<external_access_count> 个允许对外分享;其中 <external_without_label_count> 个同时缺少密级标签。
- 中优先级待复核:<tenant_link_count> 个公司内知道链接可访问 / 可编辑;<wide_share_count> 个协作者管理范围较宽。
- 待策略确认:<security_count> 个复制 / 下载 / 打印范围待复核;<comment_count> 个评论范围待复核。
- 无法判断:<unsupported_or_unverified_summary>。
分级含义:
- 互联网公开链接:获得链接的任何人可能访问,最高优先级。
- 允许对外分享:外部分享能力已开启,需 owner 复核;不等于已经存在外部协作者。
- 公司内链接可访问:不是对外公开,但组织内扩散范围较宽。
- 复制 / 下载 / 打印 / 评论:是否需要收紧取决于业务 policy 和文档密级。
高优先级待复核清单:
> 按 `摘要清单展开规则` 展示。每个对象必须包含 `risk_id` 和 URL缺少 URL 时展示 token / node_token 和原因。若没有高优先级对象,只展示中优先级或待策略确认分组摘要。
- <risk_id> <path-or-title> (<type>)
URL: <url-or-token-if-url-unavailable>
Owner: <owner-or-unknown>
密级:<sec_label_name-or-missing-or-unknown>
待复核原因:<why high priority>
证据:<short user-language evidence, e.g. 对外分享=已开启;链接分享=未开启互联网公开链接>
建议动作:<recommended action>
未完全展开:
- 完整风险清单包含 <risk_manifest_count> 条;本摘要已展示 <shown_count> 条,未展示 <hidden_count> 条。
- 未展示分组:<risk_group=count summary or none>
建议下一步:
- 生成完整风险清单 artifact包含 `risk_id`、URL、owner、密级、证据字段、建议动作和 `selected` 列。
- 基于 risk_id、风险分组、owner、路径、URL 或 artifact 中 `selected=true` 的行生成只读整改 dry-run。
- 只针对最高优先级目标进入写入确认流程,例如关闭互联网公开链接或收紧对外分享;写入前仍需二次确认。
- 按 owner / 密级生成复核清单。
- 继续读取访问记录,判断低活跃高暴露。
剩余限制:
- <do not claim collaborator-list verification if unsupported>
- <external_access_entity=open or external_access=true only means sharing outside is allowed, not that external collaborators exist>
- <missing view_records / DLP / AI index status / audit log limitations>
```
## 可操作风险清单
完整风险清单用于让用户选择后续治理范围。Markdown / CSV / 飞书文档报告都必须包含以下字段;如果某种格式无法完整展示嵌套证据,使用短文本摘要,保留 `risk_id` 和 URL。
```text
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
生成时间:<timestamp>
用途:用户可按 risk_id、priority、risk_group、owner、path、URL 或 selected=true 选择治理对象。
| risk_id | priority | Path | URL | Type | Owner | sec_label | risk_group | evidence | recommended_action | current_setting | target_setting | selected | decision | status | skip_reason |
|---------|----------|------|-----|------|-------|-----------|------------|----------|--------------------|-----------------|----------------|----------|----------|--------|-------------|
| PG-001 | P1 | <path> | <url-or-token> | <type> | <owner-or-unknown> | <sec-label-or-missing> | <risk_group> | <short evidence> | <recommended-action> | <field=value> | <field=value-or-owner-review> | false | undecided | pending | <none-or-reason> |
```
字段规则:
- `risk_id` 按 priority、risk_group、normalized path、URL、canonical token / node_token 稳定排序生成URL 缺失时必须使用 token / node_token 作为 tie-breaker。同名、同路径、shortcut 或多个 Wiki 入口不能只靠 path 生成编号;同一次诊断中不得重复。
- `priority` 使用 `P0``P1``P2``PolicyReview``Unknown`;面向用户展示时可译为“最高优先级 / 高优先级待复核 / 中优先级待复核 / 待策略确认 / 无法判断”。
- `selected` 默认 `false`;用户可在 CSV / 飞书文档表格中改为 `true`,或在聊天中直接说 “处理 PG-001、PG-003”。
- `decision` 表示用户决策:`undecided``keep``dry_run``confirm_write``skip`
- `status` 表示执行状态:`pending``dry_run_ready``confirmed``executed``verified``failed``skipped`
- `target_setting` 是建议目标状态,不代表已执行;没有明确 policy 时只能写 owner review / policy review。
## 治理选择交互
用户基于完整风险清单继续治理时Agent 必须先解析选择范围,再生成只读 dry-run
```text
可接受的用户选择:
- 处理 PG-001、PG-003、PG-008把互联网公开链接关闭。
- 先处理所有 risk_group=internet_public_link不处理 external_access_only。
- 把 CSV / 飞书文档里 selected=true 的行生成整改 dry-run。
- PG-003 先跳过,只处理 PG-001。
Agent 必须回复:
- 已选择对象数:<count>
- 选择来源:<risk_id list / risk_group / selected=true / URL / path>
- 将执行的下一步:生成 dry-run不执行写入
- 需要跳过或重新确认的对象:<missing risk_id / unsupported / changed_since_report / no manage_public>
```
如果用户选择来自旧报告或外部 artifact生成 dry-run 前必须对所选目标重新读取当前权限。当前设置和报告快照不一致时,标记为 `changed_since_report`,不要直接沿用旧字段执行。
## 权限设置清单
```text
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
| Path | URL | Type | link_share_entity | external_access_entity / external_access | invite_external | share_entity | manage_collaborator_entity | copy_entity | security_entity | comment_entity | lock_switch | sec_label_name | 建议动作 | 限制 |
|------|-----|------|-------------------|------------------------------------------|-----------------|--------------|----------------------------|-------------|-----------------|----------------|-------------|----------------|----------|------|
| <path> | <url-or-token> | <type> | <value> | <value> | <value-or-unknown> | <value> | <value-or-unknown> | <value-or-unknown> | <value> | <value> | <value-or-unknown> | <value-or-missing> | <recommended-action> | <unsupported-or-none> |
```
## 访问复核清单
```text
范围:<wiki_space / wiki_node / drive_folder / explicit_list> <name-or-id>
复核对象数:<count>
| Owner | Path | URL | Type | 密级 | 风险标签 | 当前权限摘要 | 最近访问证据 | 建议动作 |
|-------|------|-----|------|------|----------|--------------|--------------|----------|
| <owner-or-unknown> | <path> | <url-or-token> | <type> | <sec-label-or-missing> | <labels> | <link/external/share/security/comment> | <uv/pv/last_view_or_unknown> | <keep / tighten / owner review / unsupported> |
限制:<unsupported_checks / discovery_blockers / none>
```
## 整改 dry-run
```text
将生成整改计划,不执行写入:
- 范围:<scope>
- 选择来源:<risk_id list / risk_group / selected=true artifact / URL list>
- 候选目标数:<count>
- 计划执行命令:<command family>
- 重新读取已对所选目标重新读取当前权限changed_since_report=<count>
- 字段变更:
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
请确认是否进入写入确认。
```
## 批量权限申请确认
```text
将逐个发起 <view / edit> 权限申请:
- 候选目标数:<count>
- 命令类型drive +apply-permission
- 风险write每个请求都会通知 owner
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
候选示例:
- <risk_id> <title> (<type>, <url-or-token>)<reason>
请确认是否对上述候选目标发起权限申请。
```
## owner 转移确认
```text
将逐个转移 owner
- 候选目标数:<count>
- 命令类型drive permission.members transfer_owner
- 风险high-risk-write会改变文档 owner可能影响原 owner 权限和文档所在位置
- 新 owner 映射:<same_new_owner / per_target_new_owner>
- 全局新 owner<member_id> (<member_type>);仅当所有候选目标的新 owner 相同时展示,否则省略
- 通知新 owner<need_notification>
- 原 owner 权限:<remove_old_owner=true / old_owner_perm>
- 个人空间位置:<stay_put>
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
- 验证方式:执行后重新读取 metadata ownermetadata 不支持的类型标记为 partial
- 回滚边界:不做自动回滚;如需恢复 owner必须另起一次反向 owner 转移确认
候选示例:
- <risk_id> <title> (<type>, <url-or-token>):当前 owner=<owner-or-unknown> -> 新 owner=<member_id> (<member_type>)
请确认是否对上述候选目标转移 owner。
```
## 确认请求
```text
将执行 <operation>
- 目标:<risk_id> <title> (<type>, <url-or-token>)
- 命令类型:<command family>
- 风险:<risk_level>
- 字段变更:
- <field>: <old> -> <new>
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
请确认是否执行。
```
## 最终摘要
```text
已完成:<read checks / writes>
验证:<fresh read result or async permission-request approval note>
清单状态:<risk_id status updates / not applicable>
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
剩余限制:<unsupported_checks / partial facts / approvals>
```

View File

@@ -0,0 +1,207 @@
# lark-drive 权限治理 Workflow
Workflow id: `permission_governance`
Risk / Structure: `R2` / `S2`
本文实现已注册的权限治理 workflow。执行前必须先读取 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
## 适用范围
当用户要求检查或治理 Drive / Docs / Wiki 资产访问权限时,使用本 workflow。典型意图包括
- 单资源公开性、外部访问、公司内链接、分享 / 复制 / 下载 / 评论设置检查。
- 多资源、Wiki space / node、Drive folder 或个人文档库的权限风险诊断和权限设置清单。
- 访问复核、低活跃高暴露、权限申请、owner 转移、密级标签调整、AI Agent / RAG 前置权限治理。
- 只读整改 dry-run或经确认后的权限收紧 / 权限申请 / owner 转移 / 密级标签更新。
目标可以是明确 URL / token、小规模明确列表、Wiki space / Wiki node 或 Drive folder。容器范围必须先只读 `DISCOVER_TARGETS` 并产出覆盖摘要;这里的"所有文档"只表示当前身份在确认范围内可枚举到的文档。任何写入都必须再次确认。
单目标轻量路径:用户只问“是否对外公开 / 外部可访问 / 公司内链接可见”且目标是单个明确 URL / token 时,设置 `intent=public_exposure_check``target_scope=single_resource`,走 `PARSE_INTENT -> TARGET_INSPECT -> FACT_READ -> RISK_ASSESS -> DONE`。该路径是 `target_count=1` 的轻量输出模式,不是独立判断逻辑;不执行 `DISCOVER_TARGETS`、不生成 `risk_manifest` / `risk_id`,只输出结论、权限含义、检查边界和必要下一步。
## Target Set Evaluation
本 workflow 不按“单篇 / 多篇 / 容器”复制权限判断逻辑。所有范围先归一为 target set再对每个可审计目标生成 `per_target_permission_assessment`,最后按目标数量和风险分组聚合输出。
| target_scope | Target Collection | Output Mode |
|--------------|-------------------|-------------|
| `single_resource` | 直接解析一个 URL / token | `target_count=1` 时轻量渲染;不生成 `risk_manifest` |
| `explicit_list` | 用户给出的多个 URL / token 逐个 inspect / normalize | 逐目标渲染摘要;需要后续治理时生成稳定 `risk_id` |
| `wiki_space` / `wiki_node` / `drive_folder` | 先只读递归发现,再归一化为 `discovered_targets` | 输出覆盖情况、风险分组、可定位待复核对象和 artifact / dry-run CTA |
特殊的是目标收集和输出聚合,不是权限语义。`link_access``external_sharing``copy_scope``security_scope``comment_scope``sec_label``check_scope` 等语义字段必须在单目标、多目标明确列表和容器发现目标之间复用。
## 非目标
本 workflow 不处理:
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
- 内容审查、过期内容判断或知识质量评分。
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner不建模 backup owner 或负责人绑定关系。
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
- 未按范围确认的批量写入。
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
## Progressive Load Map
本表只规定每个 state 需要加载的额外上下文;命令可用范围以 `Command Map` 为准。需要拼装具体 `lark-cli` 命令时,再按需读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。
| State | Required Reference |
|-------|--------------------|
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get``lark-cli schema drive.file.view_records.list` |
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
| `VERIFY` | 复用 `FACT_READ` 阶段使用的 read schemas |
## Runtime State Extension
本 workflow 在共享 `Artifact Contract` 基础上扩展以下字段组:
| Group | Fields | Meaning |
|-------|--------|---------|
| Scope | `intent`, `target_scope`, `targets`, `discovered_targets`, `coverage_summary`, `discovery_blockers` | 记录用户意图、确认范围、直接目标、容器发现目标和未覆盖范围 |
| Facts | `metadata_facts`, `public_permission_facts`, `activity_facts`, `manage_public_auth` | 记录 metadata、公共访问与协作权限、访问证据以及写前 `manage_public` 校验 |
| Assessment | `per_target_permission_assessments`, `risk_findings`, `unsupported_checks` | 记录逐目标语义判断、带 `risk_id` / URL / owner / sec_label / evidence / action 的风险发现,以及无法执行的检查 |
| Governance | `risk_manifest`, `selected_risk_items`, `access_review_items`, `permission_request_candidates`, `owner_transfer_candidates` | 支持用户按 `risk_id`、风险分组、owner、路径、URL 或 artifact `selected=true` 选择治理范围,并记录 owner 转移候选 |
| Execution | `remediation_plan`, `owner_transfer_plan`, `public_permission_snapshots` | 记录 dry-run / 已确认整改计划、owner 转移计划、字段 diff、验证方式和 public-permission 有限回滚快照 |
## Execution State Machine
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|---------------|---------------|--------------------|---------------|------------|
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check``target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check``target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type只补读文档公共访问和协作权限设置在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy则对照 policy`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
| `VERIFY` | `verify` | 重新执行支持的读取,并与目标状态对比 | 验证表和剩余缺口 | `false` | `DONE` |
| `DONE` | `done` | 停止 | 最终回复,包含完成事项、验证结果和剩余风险 | `false` | End |
## Command Map
本 workflow 只能使用以下 command families
| State | Allowed Command Families | Purpose |
|-------|--------------------------|---------|
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request只允许单目标、小列表或已明确确认的候选列表逐个执行 |
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings必须传 `--yes` |
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner必须传 `--yes` |
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
## Command Patterns
本入口不内联命令样例。需要拼装具体 `lark-cli` 命令时,按当前 state 读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。命令是否允许执行仍以 `Command Map` 和写入规则为准。
## Discovery Rules
容器范围只能先做只读发现和覆盖摘要,不能在发现阶段执行权限申请、权限 patch 或密级更新。
通用规则:
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers``unsupported_checks`
2. 发现阶段必须生成稳定 `path`。不要只保存 title同名文档必须能通过 path 或 token 区分。
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides`;未来新增类型以运行时 schema 为准。
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`URL、owner、密级等 metadata 可能进入 `unsupported_checks`
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker不要默认展示内部 page token / cursor。
Wiki space / node 发现:
1. `/wiki/space/<space_id>` 直接解析为 `target_scope=wiki_space`。不要因为 `drive +inspect` 对该 URL 返回 not found 就停止。
2.`wiki +node-list --space-id <space_id>` 读取根节点;当节点 `has_child=true` 时,用该节点的 `node_token` 继续递归读取子节点。
3. Wiki 节点必须同时保留 `node_token``obj_token``obj_type`。权限读取优先用 `type=wiki` + `node_token` 表达 Wiki 节点权限;元数据补充可使用 `obj_type` + `obj_token`
4. 如果节点只有 `obj_token` / `obj_type`,但无法确认 Wiki 节点权限 token保留该目标为 partial并在 `unsupported_checks` 中说明只能读取底层对象或无法完整判断 Wiki 节点权限。
Drive folder 发现:
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files``has_more``next_page_token`。不要把第一页数量当作完整范围。
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
## Fact Read Rules
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets``discovered_targets` 超过 200 个时,必须分批读取并合并结果。
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks``partial`,不要阻断其他目标。
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑差异只体现在目标来源、coverage summary 和输出聚合。
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed``manage_collaborator_entity``copy_entity``lock_switch` 等字段缺失时标记为 unknown不要伪造未识别字段保留在 raw evidence / partial note 中。
7. `drive file.statistics get``drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
8. 访问统计 / 访问记录当前只对 `doc``docx``sheet``bitable``mindnote``wiki``file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
## Risk Classification
风险标签只能作为 evidence labels。除非用户提供明确 policy否则不要表述为绝对违规、已泄露或已外部访问。
默认优先级面向用户决策,而不是制造告警感:
- `P0``link_share_entity=anyone_readable/anyone_editable`,互联网公开链接候选风险。
- `P1``external_access_entity=open` / `external_access=true`、关联组织访问、公司内链接可编辑,或外部分享且缺少 / 低于 policy 密级标签。
- `P2`:公司内知道链接可读、协作者管理范围较宽。
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
- `Unknown`读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
## 写入规则
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
- `drive permission.public get` 只用于 `drive +inspect``DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`
- 用户显式确认批量权限申请后,也必须逐个目标顺序调用 `drive +apply-permission`,并在结果中区分已发起申请、失败、无法构造申请请求和未发现目标。
- `drive permission.members transfer_owner` 属于 owner 转移高风险写入。必须先确认目标、当前 owner、新 owner 的 `member_id` / `member_type``need_notification``remove_old_owner``old_owner_perm``stay_put`、执行顺序和验证方式;不能只凭姓名猜测新 owner。
- owner 转移没有 `permission.members auth` 的等价 precheck。执行前只能用 schema 和当前 metadata 做计划,执行后必须用 `drive metas batch_query` fresh read 验证 ownermetadata 不支持的类型必须把验证标记为 partial。
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true``old_owner_perm` 降权必须单独在确认中高亮。
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明不覆盖协作者、owner、继承权限或密级标签。
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
## 未来扩展边界
以下能力已有部分 CLI surface 或用户价值,但不要在当前 workflow 中作为可执行分支直接调用:
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner并走本 workflow 的 owner-transfer 确认。
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
## 输出策略
- 默认 summary-first单目标输出简短审计摘要多目标明确列表输出逐目标摘要容器目标输出安全诊断报告摘要不堆叠字段计数。
- 单目标 `public_exposure_check` 按 outputs 的 `Semantic Rendering` 渲染 `per_target_permission_assessment`,输出用户语言结论和检查边界;默认不展示底层字段名、风险清单或整改 CTA。
- 容器安全诊断必须包含一句话结论、覆盖情况、风险分级、可定位待复核对象、建议下一步和剩余限制。
- 待复核对象必须包含稳定 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、证据和建议动作缺少 URL 时展示 token / node_token 和原因。
- 容器摘要按规模渐进披露,不能固定 Top N未完全展开时必须说明完整清单总数并给出生成 artifact / dry-run / owner 复核清单等 CTA。
- 面向用户优先使用业务语言和“候选风险 / 待复核 / 待策略确认”;底层字段只作为证据。完整模板按需读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md)。
- 不要默认创建文件、飞书文档或长表格;最终回复必须包含已完成事项、验证结果和剩余限制。异步权限申请审批只能表述为“已发起申请”。

View File

@@ -0,0 +1,130 @@
# lark-drive Workflow 总框架
本文是 `lark-drive` workflow 总框架的运行协议和注册表。它面向 AI Agent 执行,只负责路由已纳入本总框架的 workflow。
`Workflow Registry` 是本总框架的唯一注册来源。未命中 registry 的请求必须按“未注册 workflow 处理”执行,不要按已有 workflow 类推扩展。
## 必读上下文
执行本总框架内的 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
下游 reference 只能按需逐步加载。不要因为命中本总框架,就预加载所有 workflow 文件或相关 skill。
## 能力边界
`lark-drive` workflow 总框架以 `lark-drive` 作为 Drive / Docs / Wiki 资产编排的总入口。其他领域 skill 只有在已纳入本总框架的 workflow 明确需要时,才作为辅助能力加载。
| Layer | Owns | Must Not Own |
|-------|------|--------------|
| `lark-drive/SKILL.md` | 用户意图到具体 workflow entry 的短路由 | 长流程逻辑、未注册场景 |
| `lark-drive-workflow.md` | 共享运行协议、Artifact Contract、Workflow Registry、加载规则 | 非运行时背景说明、宽泛路线图、场景专项执行细节 |
| Registered workflow file | 场景范围、状态机、Command Map、确认门槛、验证规则 | 其他场景、隐藏写入、未被 CLI/API 支持的能力声明 |
## 执行协议
每个已纳入本总框架的 workflow 必须遵循同一条执行骨架:
```text
route -> scope -> read -> assess/plan -> confirm -> execute -> verify -> done
```
运行规则:
1. 在读取或写入资产前,先把用户意图解析到唯一一个已纳入本总框架的 workflow。
2. 在昂贵读取或写入规划前,先解析并确认 `target_scope`
3. 事实必须来自可执行 CLI 命令或被引用 skill不要只凭目录结构推断治理结论。
4. 无法执行的检查必须记录到 `unsupported_checks`,不能静默省略。
5. 写入前必须产出计划。每一次写入都需要用户对准确范围和 command family 显式确认。
6. CLI/API 支持验证时,写入后必须用 fresh read 验证。
7. 结束时进入 `done`,返回已完成事项、验证结果和剩余限制。不要把尚未完成的外部审批描述成已完成。
## Artifact Contract
每个已纳入本总框架的 workflow 必须维护以下内部字段:
| Field | Meaning |
|-------|---------|
| `workflow_id` | 本总框架注册的 workflow 名称,例如 `permission_governance` |
| `current_state` | 当前 workflow 状态 |
| `target_scope` | 已确认的目标范围和用户原始输入 |
| `identity` | 当前身份和执行视角,通常为 `user` |
| `facts` | 从 CLI 读取或引用 skill 获取的证据 |
| `plan_items` | 候选动作;每项包含 command family、target、risk、verification method |
| `unsupported_checks` | 因 CLI/API 覆盖、目标类型、认证或范围限制而无法执行的检查 |
| `partial` | 结果是否不完整,以及不完整原因 |
| `execution_results` | 已确认写入的执行结果 |
| `verification_results` | fresh read 验证结果,或明确的异步审批限制 |
用户可见输出默认使用简洁 chat summary。只有在用户要求、结果过大不适合聊天展示或当前 workflow 明确要求共享产物时,才创建本地文件或飞书文档。
## Workflow Entry Contract
每个已纳入本总框架的 workflow entry file 必须让 Agent 能直接判断和执行:
- 何时进入该 workflow以及哪些需求不属于该 workflow
- 如何映射到共享执行骨架的 state machine
- 当前 state 需要按需加载哪些 reference
- 哪些 command family 可用,以及读写风险边界;
- 写入前如何确认,写入后如何验证;
- 最终回复必须包含哪些字段,或使用哪些 output templates。
每个纳入本总框架的 workflow 默认从一个独立 reference 文件开始。只有当写入、回滚或验证流程复杂到影响可读性时,才继续拆 phase 文件。
## Risk / Structure Gate
每个纳入本总框架的 workflow 都必须同时声明 `Risk Level``Structure Level`。风险等级决定安全门槛;结构等级决定文件拆分。高风险写入不等于必须拆 phase。
Risk Level
| Level | Meaning | Runtime Requirement |
|-------|---------|---------------------|
| `R0` | read-only只读发现、分析、报告 | 记录事实来源、`unsupported_checks``partial` 原因 |
| `R1` | low-risk write创建草稿、生成临时产物等低风险写入 | 写前说明范围,写后返回结果链接或标识 |
| `R2` | high-risk write权限变更、批量移动、标签修改等高风险写入 | 写前计划、准确 diff、用户显式确认、fresh read 验证 |
| `R3` | destructive / recovery-sensitive write删除、自动归档、双向同步、rollback cleanup | 恢复边界、执行日志、分批策略、失败停止条件和单独确认 |
Structure Level
| Level | File Shape | When To Use |
|-------|------------|-------------|
| `S1` | compact entry only | 只读、轻量审计、简单计划,无复杂写入 |
| `S2` | entry + optional `commands` / `outputs` / `artifacts` references | 有命令样例、输出模板、少量高风险写入,但状态链可集中表达 |
| `S3` | entry + phase files + optional shared references | 多阶段写入、复杂验证、恢复 / rollback、长任务或分批执行 |
升级规则:
1. 新 workflow 默认从 `S1` 开始。
2. Entry file 超过约 300 行时,优先拆 `commands``outputs``artifacts` reference。
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template不默认新增独立 workflow。
5. 已有样板:`permission_governance``R2/S2`;已发布的独立 `knowledge_organize``R2-R3/S3`,当前不作为本总框架 registry entry。
## 加载与拆分边界
- 每个纳入本总框架的场景默认只保留一个紧凑 workflow entry file。
- 不为未注册或未来场景创建占位 reference / registry entry。
- 只有 workflow 已经具备可执行规则时,才允许作为本总框架 workflow 出现在 `SKILL.md` 并加入 `Workflow Registry`
- 多文件 phase 拆分只用于执行、回滚或验证流程复杂到影响可读性的 `S3` 场景。
## Workflow Registry
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
## Workflow Loading
当用户意图匹配到本总框架已注册 workflow 时:
1. 先读取本总框架文件。
2. 只读取 `Workflow Registry` 中命中的 entry file。
3. 按该 workflow 的 progressive load map 继续加载额外 reference。
4. 除非用户改变意图,或当前 workflow 明确路由到其他 workflow否则不要读取其他 workflow 文件。
## 未注册 workflow 处理
`Workflow Registry` 是本总框架的唯一注册来源。用户请求未列入 registry 的 workflow 或组合型治理场景时:
1. 明确说明该需求暂无纳入本总框架的 `lark-drive` workflow。
2. 只在不新增本总框架 workflow 行为的前提下,将请求收窄为现有 skill / CLI 可执行的原子操作。
3. 不要类比本总框架任何已注册 workflow 新增 state machine、artifact shape、风险分类、写入行为或验证结论。

View File

@@ -53,7 +53,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
## Resource Rendering
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `![Image](img_xxx)`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Two ways to get the binaries:
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
@@ -61,7 +61,7 @@ Two ways to get the binaries:
| Resource Type | Marker in Content | Behavior |
|---------|-------------|------|
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| Image | `![Image](img_xxx)` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |

View File

@@ -32,7 +32,7 @@ When enabled:
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `![Image](img_xxx)`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
## Scope requirement

View File

@@ -90,7 +90,7 @@ lark-cli im +messages-mget --message-ids "om_aaa,om_bbb"
1. **Use JSON for full content:** table output truncates content. Use `--format json` when the full body matters.
2. **Sender names are already enriched:** the command resolves sender names automatically, so no extra lookup is required.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `[Image: img_xxx]`. Use `+messages-resources-download` when you need the binary resource.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `![Image](img_xxx)`. Use `+messages-resources-download` when you need the binary resource.
4. **Batching is more efficient:** fetching multiple IDs in one request is better than calling the API repeatedly.
## References

View File

@@ -152,7 +152,7 @@ lark-cli im +threads-messages-list --thread <thread_id>
## Resource Rendering
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `![Image](img_xxx)`; resource binaries are **not** downloaded automatically.
Use `im +messages-resources-download` if you need to fetch the underlying image or file bytes from a specific message.

View File

@@ -96,7 +96,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
## Resource Rendering
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `![Image](img_xxx)`; by default resource binaries are **not** downloaded.
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).

View File

@@ -18,16 +18,20 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| Shortcut | 说明 |
|----------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值 |
## 格式说明

View File

@@ -0,0 +1,106 @@
# okr +batch-create
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
批量创建 OKR 目标Objective和关键结果Key Result
## 推荐命令
```bash
# 批量创建 2 个 Objective各带 2 个 KR。
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input '[
{
"text": "提升产品用户体验",
"mention": ["ou_xxxxxxxx"],
"krs": [
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
{"text": "用户满意度达到 4.8 分"}
]
},
{
"text": "拓展新市场份额",
"krs": [
{"text": "新增 10 个城市覆盖"},
{"text": "市场份额提升至 25%"}
]
}
]' \
--as user
# 从文件读取输入
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input @okr_batch.json \
--as user
# 预览 API 调用Dry-run
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input @okr_batch.json \
--dry-run \
--as user
```
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|-----------|------------------------------------------------------------|
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 输入格式
```json
[
{
"text": "Objective 内容",
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
"krs": [
{
"text": "KR 内容",
"mention": ["ou_zzzzzzzz"]
}
]
}
]
```
## 工作流程
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID
2. 构造 `--input` JSON 数组,包含要创建的 Objective 和 KR
3. 执行 `lark-cli okr +batch-create --cycle-id <id> --input '...'`
## 输出
成功返回 JSON
```json
{
"ok": true,
"data": {
"created": [
{
"objective_id": "7000000000000000002",
"krs": ["7000000000000000003", "7000000000000000004"]
},
{
"objective_id": "7000000000000000005",
"krs": ["7000000000000000006"]
}
]
}
}
```
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,80 @@
# okr +indicator-update
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
直接更新目标Objective或关键结果Key Result的指标当前值无需手动查询指标 ID。
> **查询指标:** 如需查看指标详情,请使用原生 API
> - 目标指标:`lark-cli okr objective.indicators list --objective-id <id>`
> - KR 指标:`lark-cli okr key_result.indicators list --key-result-id <id>`
## 推荐命令
```bash
# 更新 Objective 的指标值
lark-cli okr +indicator-update \
--level objective \
--id 7000000000000000001 \
--value 75.5 \
--as user
# 更新 Key Result 的指标值
lark-cli okr +indicator-update \
--level key-result \
--id 7000000000000000002 \
--value 100 \
--as user
```
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------|----|--------|--------------------------------------------------------------------|
| `--level` | 是 | — | 操作层级:`objective`(更新目标指标)\| `key-result`(更新 KR 指标) |
| `--id` | 是 | — | 目标 ID 或 KR IDint64 类型) |
| `--value` | 是 | — | 新的指标当前值(数字,范围:-99999999999 到 99999999999 |
| `--dry-run`| 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取目标 ID 或 KR ID。
2. 如需查看当前指标值,使用 `objective.indicators list``key_result.indicators list` 查询。
3. 执行 `+indicator-update` 指定层级、ID 和新值。
4. 命令自动查询指标 ID 并更新当前值。
## 输出
### JSON 格式
```json
{
"ok": true,
"data": {
"indicator_id": "7000000000000000003",
"current_value": 75.5,
"level": "objective",
"target_id": "7000000000000000001"
}
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|----------------|--------|------------------------|
| `indicator_id` | string | 被更新的指标 ID |
| `current_value`| number | 更新后的指标当前值 |
| `level` | string | 操作层级:`objective` / `key-result` |
| `target_id` | string | 目标或 KR 的 ID |
## 注意事项
- 仅更新 `current_value` 字段,`unit``start_value``target_value` 等其他字段保持不变
- 若需要这些字段进行修改,使用原生接口 indicators.patch
- 指标的 `current_value_calculate_type` 必须为「手动更新」才能通过此命令修改。
## 参考
- [OKR 指标更新 API](https://open.feishu.cn/api-explorer?from=op_doc_tab&apiName=patch&project=okr&resource=okr.indicator&version=v2)
- [`lark-okr-progress-create.md`](./lark-okr-progress-create.md) — 创建进度记录
- [`lark-okr-cycle-detail.md`](./lark-okr-cycle-detail.md) — 查询周期详情获取 ID

View File

@@ -0,0 +1,81 @@
# okr +reorder
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
调整 OKR 周期下目标Objective或目标下关键结果Key Result的顺序。
## 推荐命令
```bash
# 调整 Objective 顺位
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level objective \
--ops '[
{"id": "7000000000000000002", "position": 2},
{"id": "7000000000000000003", "position": 1}
]' \
--as user
# 调整 KR 顺位(需指定 --objective-id
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level key-result \
--objective-id 7000000000000000002 \
--ops '[
{"id": "7000000000000000004", "position": 1},
{"id": "7000000000000000005", "position": 2}
]' \
--as user
# 从文件读取 ops
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level objective \
--ops @reorder_ops.json \
--as user
```
- 不允许将多个 objective/key-result 放在同一个位置下
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|--------|---------------------------------------------------------|
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标顺序)\| `key-result`(调整目标下 KR 顺序) |
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型)。 |
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
| `--ops` | 是 | — | JSON 数组格式的顺位调整操作。支持 `@文件路径``@-` 从 stdin 读取。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取周期 ID、目标 ID 和 KR ID。
2. 构造 `--ops` JSON 数组,指定要调整的 ID 和新 position执行命令。
3. 返回调整后的完整顺序。
## 输出
成功返回 JSON以调整 Objective 位置为例):
```json
{
"ok": true,
"data": {
"level": "objective",
"cycle_id": "7000000000000000001",
"total": 3,
"ordered": [
"7000000000000000003",
"7000000000000000002",
"7000000000000000004"
]
}
}
```
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,96 @@
# okr +weight
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
调整 OKR 周期下目标Objective或目标下关键结果Key Result的权重。支持部分指定权重未指定的按原权重比例自动分配。
## 推荐命令
```bash
# 调整 Objective 权重(部分指定,剩余自动分配)
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level objective \
--weights '[
{"id": "7000000000000000002", "weight": 0.6},
{"id": "7000000000000000003", "weight": 0.3}
]' \
--as user
# 调整 KR 权重(全部指定,和为 1
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level key-result \
--objective-id 7000000000000000002 \
--weights '[
{"id": "7000000000000000004", "weight": 0.6},
{"id": "7000000000000000005", "weight": 0.4}
]' \
--as user
# 从文件读取 weights
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level objective \
--weights @weights.json \
--as user
```
参数限制: 请求中的权重保留三位小数,分配的所有权重和不能大于 1 (小于等于 1 是允许的)。
### 权重归一化
- 在 OKR 中,一个周期下所有 Objective 和 一个 Objective 下所有 Key Result 的权重和固定为 1.
- 在使用 +weight shortcut 分配 OKR 权重时,已分配的总权重不得超过 1。
- 若已分配的权重 < 1剩余的权重会按照原始权重的比例均分到未指定的 Objective/Key Result 下。
- 若所有 Objective/Key Result 均分配了权重但和 < 1剩余的权重会计算在最后一个 Objective/Key Result 下。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|--------|---------------------------------------------------------------------|
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标权重)\| `key-result`(调整目标下 KR 权重) |
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
| `--weights` | 是 | — | JSON 数组格式的权重分配。支持 `@文件路径``@-` 从 stdin 读取。权重保留三位小数,分配的所有权重和不能大于 1 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取周期 ID、目标 ID、KR ID 和当前权重。
2. 构造 `--weights` JSON 数组,指定要调整的 ID 和权重,执行命令。
3. 返回调整后的完整权重列表。
## 输出
成功返回 JSON
```json
{
"ok": true,
"data": {
"level": "objective",
"cycle_id": "7000000000000000001",
"total": 3,
"weights": [
{"id": "7000000000000000002", "weight": 0.6},
{"id": "7000000000000000003", "weight": 0.3},
{"id": "7000000000000000004", "weight": 0.1}
]
}
}
```
## 关于 1001001 错误
有时,即使输入的参数完全正确, +weight 也会返回 1001001 错误。这是因为你的租户设置中,不一定开启了目标或关键结果的设置权重功能。
若你确认输入的参数无误cycle-id/objective-id 正确weights 中的 id 均是同一个周期下的目标或同一个目标下的关键结果weights 中的权重和 <1,
不必进一步尝试,你需要向用户确认 OKR 应用目前是否开启了目标或关键结果的设置权重功能。
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -18,6 +18,7 @@ metadata:
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot``lark-slides-screenshot.md` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
@@ -82,6 +83,7 @@ lark-cli auth login --domain slides
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)

View File

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

View File

@@ -2,10 +2,11 @@
本文档基于 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 整理,说明飞书 Slides XML SchemaSML 2.0)的核心结构和常用写法。
> **注意**:所有提交给 API 的 XML整篇或片段都**不要带 `<?xml ... ?>` 声明**——slides 后端会拒绝它,直接从根元素写起。标签白名单与文本转义规则见 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
## 基本结构
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>演示文稿标题</title>
<slide>
@@ -224,7 +225,7 @@
| `src` 形式 | 说明 |
|---|---|
| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX` | 通过 `slides +media-upload` 上传后返回的 token |
| `@<本地路径>`(如 `@./assets/chart.png` | **仅在 `slides +create --slides` 中可用**CLI 会自动上传该文件并替换为 file_token |
| `@<本地路径>`(如 `@./assets/chart.png` | **仅在 `slides +create --slides` 中可用**CLI 会自动上传该文件并替换为 file_token。路径也要按 XML 规则转义:文件名含 `&` 时写 `@./Q1&amp;Q2.png`CLI 反转义后查找文件 |
> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。
@@ -305,7 +306,6 @@
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>季度报告</title>
<theme>

View File

@@ -9,10 +9,30 @@
3. `<slide>` 直接子元素只有 `<style>``<data>``<note>`
4. 页面中的文本通常通过 `<content>` 表达,而不是把 `<title>``<body>` 直接挂在 `<slide>`
## 标签白名单与禁令(生成前必看)
**只使用本文档和 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 中列出的元素。白名单之外的标签一律不写**——后端引擎对未定义标签直接整页拒绝(`tag not supported`),不会降级渲染。这条规则覆盖一切未列出的标签,无需记黑名单,但以下是线上被拒实锤的高频踩坑标签,显式点名:
`<audio>``<video>``<timeline>``<animation>``<trigger>``<header>`
PPT 语义的「富媒体 / 动画 / 时间轴」需求请改用白名单内的替代元素:
| 想要的效果 | 用什么替代 |
|------|------|
| 数据图表 / 时间轴 / 流程图 | `chart*` 系列48 个图表元素)、`mermaid` |
| 自由绘图 / 复杂图形 | `whiteboard``shape` / `line` / `polyline` |
| 图标 / 插图 | `icon``img` |
| 音频 / 视频 | 无原生支持,用 `img` 截图 + 链接文本替代 |
### 文本转义与片段格式(提交被拒的常见原因)
- 文本里的字面 `&` 必须写成 `&amp;``<` 写成 `&lt;`——裸 `&`(如 `R&D`、URL 中的 `&`)是 XML 语法错误,整次提交都会被拒
- 只能用 XML 内置实体 `&amp;` `&lt;` `&gt;` `&apos;` `&quot;`**HTML 实体如 `&nbsp;``&mdash;` 不存在**,会直接解析失败,空格就写普通空格
- **`--slides` / `--parts` 等 XML 片段不要带 `<?xml ... ?>` 声明**——slides 后端会拒绝它,直接从根元素写起
## 最小可用示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide>
<data>
@@ -132,7 +152,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
```
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。占位符路径同样遵守 XML 转义规则:文件名含 `&` 时写 `src="@./Q1&amp;Q2.png"`CLI 会先反转义再查找本地文件。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
> **注意**`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
func TestOKR_BatchCreateDryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", "123456",
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/"), "dry-run should contain KR API path prefix, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_BatchCreateDryRun_WithUserIDType validates +batch-create dry-run with --user-id-type.
func TestOKR_BatchCreateDryRun_WithUserIDType(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", "123456",
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
"--user-id-type", "user_id",
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "user_id"), "dry-run should contain user-id-type, got: %s", output)
}
// TestOKR_ReorderDryRun validates +reorder dry-run output contains expected API paths.
func TestOKR_ReorderDryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", "123456",
"--level", "objective",
"--ops", `[{"id":"obj_1","position":2},{"id":"obj_2","position":1}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_ReorderDryRun_KR validates +reorder dry-run with --level=key-result.
func TestOKR_ReorderDryRun_KR(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", "123456",
"--objective-id", "789",
"--level", "key-result",
"--ops", `[{"id":"1001","position":2},{"id":"1002","position":1}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
}
// TestOKR_WeightDryRun validates +weight dry-run output contains expected API paths.
func TestOKR_WeightDryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", "123456",
"--level", "objective",
"--weights", `[{"id":"obj_1","weight":0.6},{"id":"obj_2","weight":0.4}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_WeightDryRun_KR validates +weight dry-run with --level=key-result.
func TestOKR_WeightDryRun_KR(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", "123456",
"--objective-id", "789",
"--level", "key-result",
"--weights", `[{"id":"1001","weight":0.5},{"id":"1002","weight":0.5}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
}
// --- Live E2E tests (require user token, skip otherwise) ---
// getTestCycleID returns the test cycle ID from env var, or skips the test.
func getTestCycleID(t *testing.T) string {
t.Helper()
cycleID := os.Getenv("OKR_TEST_CYCLE_ID")
if cycleID == "" {
t.Skip("OKR_TEST_CYCLE_ID not set; set to a valid cycle ID for live E2E tests")
}
return cycleID
}
// liveTestCreated tracks resources created during a live test for cleanup.
type liveTestCreated struct {
ObjectiveID string
KRIDs []string
}
// createTestObjectives creates test objectives using +batch-create and returns the created IDs.
func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suffix string) []liveTestCreated {
t.Helper()
input := []map[string]interface{}{
{
"text": fmt.Sprintf("E2E Test Objective A %s", suffix),
"krs": []map[string]interface{}{
{"text": fmt.Sprintf("E2E Test KR A1 %s", suffix)},
{"text": fmt.Sprintf("E2E Test KR A2 %s", suffix)},
},
},
{
"text": fmt.Sprintf("E2E Test Objective B %s", suffix),
"krs": []map[string]interface{}{
{"text": fmt.Sprintf("E2E Test KR B1 %s", suffix)},
},
},
}
inputJSON, _ := json.Marshal(input)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
var created []liveTestCreated
createdArr := gjson.Get(result.Stdout, "data.created").Array()
for _, obj := range createdArr {
objectiveID := obj.Get("objective_id").String()
var krIDs []string
for _, kr := range obj.Get("krs").Array() {
krIDs = append(krIDs, kr.String())
}
created = append(created, liveTestCreated{
ObjectiveID: objectiveID,
KRIDs: krIDs,
})
}
require.Len(t, created, 2, "expected 2 objectives created")
require.Len(t, created[0].KRIDs, 2, "expected 2 KRs for first objective")
require.Len(t, created[1].KRIDs, 1, "expected 1 KR for second objective")
require.NotEmpty(t, created[0].ObjectiveID, "objective_id should not be empty")
require.NotEmpty(t, created[0].KRIDs[0], "kr_id should not be empty")
return created
}
// cleanupLiveTest deletes KRs first, then objectives, using the raw API service commands.
func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
t.Helper()
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
// Delete in reverse order: KRs first, then objectives
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete KRs first (reverse order)
for j := len(obj.KRIDs) - 1; j >= 0; j-- {
krID := obj.KRIDs[j]
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"okr", "v2/key_results", "delete",
"--key-result-id", krID,
"--yes",
},
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
case <-cleanupCtx.Done():
return
case <-time.After(200 * time.Millisecond):
}
}
// Then delete the objective
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"okr", "v2/objectives", "delete",
"--objective-id", obj.ObjectiveID,
"--yes",
},
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
select {
case <-cleanupCtx.Done():
return
case <-time.After(200 * time.Millisecond):
}
}
}
}
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
func TestOKR_BatchCreateLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
// Verify: call +cycle-detail to confirm objectives exist
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
foundCount := 0
for _, obj := range objectives {
objID := obj.Get("id").String()
for _, c := range created {
if objID == c.ObjectiveID {
foundCount++
// Verify KRs exist under this objective
krs := obj.Get("key_results").Array()
krIDs := make(map[string]bool)
for _, kr := range krs {
krIDs[kr.Get("id").String()] = true
}
for _, expectedKR := range c.KRIDs {
assert.True(t, krIDs[expectedKR], "expected KR %s to exist under objective %s", expectedKR, objID)
}
}
}
}
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
}
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
func TestOKR_ReorderLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives (A, then B)
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
objA := created[0].ObjectiveID
objB := created[1].ObjectiveID
// Reorder: swap positions (B at position 1, A at position 2)
ops := []map[string]interface{}{
{"id": objB, "position": 1},
{"id": objA, "position": 2},
}
opsJSON, _ := json.Marshal(ops)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", cycleID,
"--level", "objective",
"--ops", string(opsJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Verify order via +cycle-detail
result, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
var foundIDs []string
for _, obj := range objectives {
objID := obj.Get("id").String()
if objID == objA || objID == objB {
foundIDs = append(foundIDs, objID)
}
}
require.Len(t, foundIDs, 2, "should find both test objectives")
assert.Equal(t, objB, foundIDs[0], "after reorder, objective B should be first")
assert.Equal(t, objA, foundIDs[1], "after reorder, objective A should be second")
}
// TestOKR_WeightLive validates +weight with real API calls: create, set weights, verify sum=1.0, cleanup.
func TestOKR_WeightLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
objA := created[0].ObjectiveID
objB := created[1].ObjectiveID
// Set weights: A=0.6, B=0.4 (sum=1.0)
weights := []map[string]interface{}{
{"id": objA, "weight": 0.6},
{"id": objB, "weight": 0.4},
}
weightsJSON, _ := json.Marshal(weights)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", cycleID,
"--level", "objective",
"--weights", string(weightsJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Verify weights via +cycle-detail
result, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
var weightA, weightB float64
for _, obj := range objectives {
objID := obj.Get("id").String()
if objID == objA {
weightA = obj.Get("weight").Float()
} else if objID == objB {
weightB = obj.Get("weight").Float()
}
}
// Verify weights are set correctly (allowing for floating point tolerance)
assert.InDelta(t, 0.6, weightA, 0.001, "objective A weight should be 0.6")
assert.InDelta(t, 0.4, weightB, 0.001, "objective B weight should be 0.4")
// Verify sum = 1.0
sumWeights := weightA + weightB
assert.InDelta(t, 1.0, sumWeights, 0.001, "sum of weights should be 1.0, got %.6f", sumWeights)
}