Compare commits

...

31 Commits

Author SHA1 Message Date
陈兴炀
d752ab9a20 fix(apps): make db --environment optional, auto-select branch server-side
All db shortcuts defaulted --environment to "dev", which forced single-env
apps (whose DB lives on the online branch, with no dev branch) to fail with
"Invalid DB Branch: dev" unless the user explicitly passed --environment
online.

Change the default to empty: when --environment is omitted the CLI sends no
env, letting the server pick the branch by the app's multi-env state
(multi-env → dev, single-env → online), matching miaoda-cli's behavior of
not carrying dbBranch when unset. Explicit --environment dev|online is
unchanged; explicit dev on a single-env app still errors as expected.

- 10 db shortcuts: dbEnvFlags default "dev" → "" (+db-execute, +db-table-list,
  +db-table-get, +db-quota-get, +db-data-export, +db-data-import,
  +db-changelog-list, +db-audit-list/-set/-status)
- dry-run e2e assertions updated: default env is now unset, not "dev"
- skill docs (lark-apps-db, lark-apps-db-execute) describe the auto-select
2026-07-03 15:45:26 +08:00
zhangjun-bytedance
73be1d06ec bugfix 0702 about speaker replace (#1731) 2026-07-03 14:05:48 +08:00
liujinkun2025
cccf025599 docs(drive): document 30-char query limit for +search (#1560)
The Search v2 API rejects queries longer than 30 characters (counted by
Unicode code point, CJK 1 each) with 99992402 field validation failed —
it is a hard error, not truncation. Surface this in the --query -h help
text and the lark-drive search skill so callers compress long queries
before searching instead of hitting the error.

Change-Id: Ieb30a66edae7a573690c49719627ec8fb2500a1a
2026-07-03 11:39:07 +08:00
zhanglei-1103
7db899db01 docs: add mindnote guidance to lark-doc (#1581) 2026-07-03 10:50:25 +08:00
liangshuo-1
c2d6038aae chore: release v1.0.64 (#1725) 2026-07-02 21:58:37 +08:00
Lekko
efa3439e01 fix(update): increase npm registry fetch timeout from 5s to 15s (#1724)
The update checker fetches https://registry.npmjs.org/@larksuite/cli/latest
with a 5-second HTTP client timeout. Under high-latency network conditions
(TUN-mode proxies, VPNs, transcontinental routes), TLS handshake alone can
take 4-6 seconds, causing the check to fail with:

  context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Measured example behind a Clash TUN proxy (US node from China):
  DNS resolve:    ~0ms (fake-ip)
  TCP connect:    ~0ms (local TUN)
  TLS handshake:  4.3-5.9s  <-- bottleneck
  Total:          4.7-6.3s

curl succeeds because it has no default connect timeout, but the Go HTTP
client with Timeout=5s is too tight. The registry endpoint returns a tiny
JSON payload (<1KB), so 15s is more than enough headroom while still
failing fast on genuinely unreachable networks.

Co-authored-by: 王伟达 <weida.wang@m.com>
2026-07-02 21:36:44 +08:00
91-enjoy
9f150670f3 feat: upgrade card send (#1688) 2026-07-02 19:46:11 +08:00
liangshuo-1
578e2db4e0 fix: point permission-apply link at official /page/scope-apply entry (#1722) 2026-07-02 19:26:00 +08:00
Paulazaaza-dev
94139751d3 Add detailed command-to-reference mapping for the approval skill. (#1630) 2026-07-02 19:08:19 +08:00
shifengjuan-dev
8c3ed5d224 feat(im): add +chat-members-list shortcut for member listing (#1398)
Add a dedicated +chat-members-list shortcut that lists chat members,
returning users and bots in separate users[] / bots[] buckets. It owns its
pagination loop (mirroring the paginateLoop conventions: per-page log line,
--page-limit cap, non-advancing-token guard) because the list_members
response is multi-bucket: the generic --page-all merger is built for
single-array responses and would silently drop the bots[] bucket and the
final-page truncations[] signal.

Highlights:
- merges users[] and bots[] across pages; takes truncations[] / has_more /
  page_token from the last page so a server-side cap is never hidden
- surfaces truncations[] with a loud stderr warning when the server caps a
  bucket due to security config (the list is incomplete)
- --member-types filter (user/bot), --member-id-type, and the standard
  --page-all / --page-limit / --page-token flags
- with --page-all and no explicit --page-size, uses the max page size to
  minimize round-trips
- docs: SKILL.md Shortcuts table + references/lark-im-chat-members-list.md
2026-07-02 18:09:51 +08:00
zhanghuanxu
c982df4cf0 test: inline slide lint XML fixtures 2026-07-02 18:02:04 +08:00
zhanghuanxu
fb5ae41bca docs: remove lark slides template toolchain 2026-07-02 18:02:04 +08:00
zhanghuanxu
87e872a4c1 docs: add lark slides generation constraints 2026-07-02 18:02:04 +08:00
syh-cpdsss
ddc0f2a521 feat(okr): semi-plain text format with mention position preservation + patch shortcut (#1671)
Add semi-plain text (simple) format for OKR content I/O, and a new `+patch`
shortcut for incremental updates to objectives and key results.
2026-07-02 17:45:00 +08:00
HanShaoshuai-k
440867f1b4 fix: reduce public content token false positives 2026-07-02 17:39:49 +08:00
fangshuyu-768
d0cde9a414 Improve secure label error handling (#1707)
* Improve secure label error handling

* Address secure label review feedback
2026-07-02 15:41:02 +08:00
SunPeiYang996
075b34f9a3 chore: lark-cli docs support reference_map (#1690)
* chore:lark-cli docs support reference_map

* fix: address docs reference map review feedback

* test: harden docs reference map CI assertions
2026-07-02 13:07:42 +08:00
fangshuyu-768
3788405256 fix(doc): align word statistics compound tokens (#1706) 2026-07-02 11:43:22 +08:00
liangshuo-1
462358a746 install: warn instead of failing when checksums.txt is missing (#1712) 2026-07-01 22:50:56 +08:00
liangshuo-1
ad4d3cb874 chore: release v1.0.62 (#1710) 2026-07-01 21:41:14 +08:00
zhicong666-bytedance
171778951d feat(vc): add meeting message send shortcut (#1643)
* feat(vc): add meeting message send shortcut

* docs: refine vc meeting emoji guidance

* fix(vc): validate meeting message send conflicts

* test: add vc meeting message dry-run e2e

* fix(vc): validate meeting message send limits
2026-07-01 20:51:59 +08:00
fangshuyu-768
a6797ac2e4 Improve drive batch failure handling (#1703) 2026-07-01 18:15:14 +08:00
fangshuyu-768
d852ab311b feat(doc): add document word statistics helper (#1697) 2026-07-01 18:03:28 +08:00
HanShaoshuai-k
e8bfbab4a5 fix: reduce public content credential false positives (#1700) 2026-07-01 17:46:33 +08:00
zgz2048
3bda9e17de fix: support field create json array input (#1661) 2026-07-01 16:08:55 +08:00
ILUO
e753b15d84 fix: expose completion state in my tasks output (#1641)
* fix: expose completion state in my tasks output

* test: cover my tasks pretty completion state
2026-07-01 15:41:57 +08:00
dc-bytedance
bdffffb368 feat: interactive upgrade prompt for bare lark-cli (#1498) 2026-07-01 15:07:18 +08:00
dc-bytedance
ec6fdc9b30 feat: fail closed when checksums.txt is missing during install (#1503) 2026-07-01 13:23:23 +08:00
liangshuo-1
775ee5a501 chore: release v1.0.61 (#1695) 2026-06-30 22:18:39 +08:00
liujinkun2025
214318aa02 fix: support bot identity for drive search (#1670) 2026-06-30 21:59:51 +08:00
liangshuo-1
6f2cddfce1 fix(identity): correct identity diagnosis under external credential providers (#1693) 2026-06-30 21:56:03 +08:00
227 changed files with 15350 additions and 76599 deletions

View File

@@ -2,6 +2,62 @@
All notable changes to this project will be documented in this file.
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
- **vc**: Add meeting message send shortcut (#1643)
- **doc**: Add document word statistics helper (#1697)
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
### Bug Fixes
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
- **base**: Support JSON array input for field create (#1661)
- **task**: Expose completion state in `my tasks` output (#1641)
- **cli**: Reduce public content credential false positives (#1700)
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
@@ -1299,6 +1355,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58

View File

@@ -214,6 +214,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)

View File

@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,14 +4,19 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
return check
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

90
cmd/root_upgrade.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
// (install-method detection, output, error handling). Package-level var so
// tests can stub it and avoid real network / self-update.
var runRootUpgrade = func(cmd *cobra.Command) {
for _, c := range cmd.Root().Commands() {
if c.Name() == "update" && c.RunE != nil {
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
return
}
}
}
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
// AND no flag tokens in the raw invocation.
func isBareRootInvocation(args []string) bool {
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
}
// readYes reads one line and reports whether it is an affirmative y/yes.
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
func readYes(r io.Reader) bool {
line, _ := bufio.NewReader(r).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}
runRootUpgrade(cmd)
}
// installRootUpgradePrompt wraps the root command's RunE (set to
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}
}

191
cmd/root_upgrade_test.go Normal file
View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadYes(t *testing.T) {
cases := map[string]bool{
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
}
for in, want := range cases {
if got := readYes(strings.NewReader(in)); got != want {
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsBareRootInvocation(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
if !isBareRootInvocation([]string{}) {
t.Error("empty args + no raw flag tokens should be bare")
}
rawInvocationArgs = []string{"--profile", "x"}
if isBareRootInvocation([]string{}) {
t.Error("flag token present → not bare")
}
rawInvocationArgs = nil
if isBareRootInvocation([]string{"im"}) {
t.Error("positional arg → not bare")
}
}
func TestOfferRootUpgrade(t *testing.T) {
origV := build.Version
build.Version = "1.0.0" // release version so shouldSkip()==false
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
// This test builds a Factory literal (no NewDefault), so it never runs
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
cases := []struct {
name string
in, out, err bool
input string
latest string // "" → no state file (CheckCached nil)
optOut bool
wantPrompt, wantRun bool
}{
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Clear env that update.shouldSkip treats as "suppress" so the
// test is deterministic regardless of host (GitHub Actions sets
// CI=true, which would otherwise suppress the prompt).
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(tc.input),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: tc.in,
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
})
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
innerCalls := 0
root := &cobra.Command{Use: "lark-cli"}
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
}
if err := root.RunE(root, []string{"im"}); err != nil {
t.Fatalf("non-bare RunE err = %v", err)
}
if innerCalls != 2 {
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
}
}
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
// path (not the stub used elsewhere): from any command it must locate the
// registered "update" subcommand via cmd.Root() and invoke its RunE.
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
ran := 0
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
child := &cobra.Command{Use: "im"}
root.AddCommand(child)
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
if ran != 1 {
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
}
}
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
// when root has no RunE, installRootUpgradePrompt must not wrap it.
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}
}

View File

@@ -5,8 +5,6 @@ package whoami
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
@@ -17,6 +15,13 @@ import (
)
// whoamiResult is the structured output of `lark-cli whoami`.
//
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
// the app acting as itself; a user identity is the app acting *on behalf of* a
// person (calls are attributed to that user, who is not necessarily present).
// onBehalfOf only *names* that person and so appears only once a user is
// resolved — a user identity that is not signed in still has identity "user"
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
@@ -26,34 +31,44 @@ type whoamiResult struct {
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OpenID string `json:"openId,omitempty"`
UserName string `json:"userName,omitempty"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
}
// delegatedUser is the user a user-identity acts on behalf of.
type delegatedUser struct {
UserName string `json:"userName,omitempty"`
OpenID string `json:"openId,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
JSON bool
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. It is local-only:
// no network calls are made.
// together with the active profile, app, and token status. Output is always
// JSON — whoami is consumed by agents. With the built-in credential path it is
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
// Output is always JSON. Accept (and ignore) --json so existing
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
// mode exists.
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
_ = cmd.Flags().MarkHidden("json")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -67,10 +82,11 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Reject an explicit --as that does not resolve to a usable identity, so a
// typo like `--as admin` fails clearly instead of echoing back a bogus
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
// matches how api/service/shortcut commands validate the resolved identity.
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
if err := f.CheckStrictMode(ctx, as); err != nil {
return err
}
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
@@ -82,11 +98,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
if opts.JSON {
output.PrintJson(f.IOStreams.Out, res)
return nil
}
formatPretty(f.IOStreams.Out, res)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -94,17 +106,18 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto-detect"
return "auto_detect"
}
if strictForced != "" {
return "strict-mode"
return "strict_mode"
}
return "default-as"
return "default_as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
@@ -122,46 +135,29 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
res.Hint = diag.Bot.Hint
}
default: // user
res.Available = diag.User.Available
res.OpenID = diag.User.OpenID
res.UserName = diag.User.UserName
res.TokenStatus = diag.User.TokenStatus
if res.TokenStatus == "" {
res.TokenStatus = "missing"
// Use Status (not the raw TokenStatus) so the vocab matches the bot
// branch: "ready" means usable for both. available stays the canonical
// usable signal; tokenStatus is the readable state behind it.
res.TokenStatus = diag.User.Status
// Set onBehalfOf only when a user is actually resolved; an unresolved
// user identity (not signed in) has no one to act on behalf of yet.
if diag.User.UserName != "" || diag.User.OpenID != "" {
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
}
if !diag.User.Available {
res.Hint = "No usable user token. Run `lark-cli auth login`."
res.Hint = diag.User.Hint
}
}
return res
}
// formatPretty writes the human-readable one-glance summary.
func formatPretty(w io.Writer, r *whoamiResult) {
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
if r.Identity == string(core.AsUser) && r.UserName != "" {
if r.OpenID != "" {
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
} else {
fmt.Fprintf(w, "User: %s\n", r.UserName)
}
}
token := r.TokenStatus
if !r.Available && r.Hint != "" {
token = r.TokenStatus + " — " + r.Hint
}
// Write the label and value as separate %s args rather than one combined
// literal. A single label-colon-value literal trips the public-content
// credential scanner as a false-positive credential assignment; splitting
// the args avoids it while producing identical output.
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
}

View File

@@ -5,15 +5,19 @@ package whoami
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
@@ -28,10 +32,10 @@ func TestResolveSource(t *testing.T) {
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
{"auto detected", false, "", true, "", "auto-detect"},
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
{"default-as", false, "", false, "", "default-as"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
{"auto detected", false, "", true, "", "auto_detect"},
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
{"default_as", false, "", false, "", "default_as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -46,18 +50,19 @@ func TestResolveSource(t *testing.T) {
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "valid" {
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "ou_x" || r.UserName != "Alice" {
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
@@ -70,9 +75,9 @@ func TestBuildResult_UserValid(t *testing.T) {
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -80,8 +85,10 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
// whoami renders the diagnosed hint verbatim (single source of truth) so it
// stays correct for the external-provider path without whoami knowing about it.
if r.Hint != diag.User.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
@@ -93,16 +100,16 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default-as", diag)
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default-as" {
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "" || r.UserName != "" {
t.Fatalf("bot must not carry openId/userName: %#v", r)
if r.OnBehalfOf != nil {
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
@@ -112,9 +119,9 @@ func TestBuildResult_BotReady(t *testing.T) {
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -122,58 +129,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
}
}
func TestFormatPretty_User(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
})
out := buf.String()
for _, want := range []string{
"Profile: my-app (cli_x, lark)",
"Identity: user (auto-detect)",
"User: Alice (ou_x)",
"Token: valid",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
}
}
}
func TestFormatPretty_BotNoUserLine(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
Identity: "bot", IdentitySource: "default-as",
Available: true, TokenStatus: "ready",
})
out := buf.String()
if strings.Contains(out, "User:") {
t.Errorf("bot output must not contain User: line\n%s", out)
}
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
t.Errorf("unexpected bot output:\n%s", out)
}
}
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: false, TokenStatus: "missing",
Hint: "No usable user token. Run `lark-cli auth login`.",
})
out := buf.String()
if !strings.Contains(out, "Token: missing — No usable user token.") {
t.Errorf("expected token line with hint, got:\n%s", out)
if r.Hint != diag.Bot.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
}
}
@@ -183,7 +140,7 @@ func TestWhoami_BotJSON(t *testing.T) {
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
@@ -204,8 +161,8 @@ func TestWhoami_BotJSON(t *testing.T) {
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OpenID != "" {
t.Fatalf("bot must not carry openId: %q", got.OpenID)
if got.OnBehalfOf != nil {
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
}
}
@@ -256,3 +213,108 @@ func TestWhoami_ConfigErrorPropagates(t *testing.T) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// Regression for the external-provider blind spot: with credentials managed by
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
t.Fatalf("got %#v, want user/available/ready", got)
}
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
}
if got.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.Hint)
}
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || got.Available {
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
}
if strings.Contains(got.Hint, "auth login") {
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
}
if !strings.Contains(got.Hint, "external") {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}

View File

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

View File

@@ -18,6 +18,9 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
@@ -27,19 +30,24 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}

View File

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

View File

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

View File

@@ -10,8 +10,20 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
// Secure label endpoint codes observed from drive +secure-label-update
// failure telemetry.
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -102,6 +102,35 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
}
}
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_Unknown(t *testing.T) {
_, ok := LookupCodeMeta(999999)
if ok {

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
if ctx == nil {
ctx = context.Background()
}
// An external provider mints tokens on demand and blocks interactive auth,
// so the built-in keychain heuristics and "auth login" hints don't apply.
if provider := activeExternalProvider(ctx, f); provider != "" {
return diagnoseExternal(ctx, f, cfg, provider, verify)
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
// activeExternalProvider returns the active extension provider name, or "".
// An error degrades to the built-in path: an unreachable provider would already
// have failed the f.Config() that produced cfg.
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
if f == nil || f.Credential == nil {
return ""
}
name, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return ""
}
return name
}
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
if cfg == nil || cfg.AppID == "" {
notConfigured := Identity{
Status: StatusNotConfigured,
Message: "not configured (missing app config)",
Hint: externalCredentialHint(provider),
}
return Result{Bot: notConfigured, User: notConfigured}
}
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
return Result{
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
}
}
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("Bot", provider)
}
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("User", provider)
}
// enrichUserInfo populates UserOpenId only after the provider returns and
// verifies a UAT (and clears it on failure), so a resolved open id is the
// external analogue of a keychain token being present.
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: not signed in via credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
id := Identity{
Status: StatusReady,
Available: true,
TokenStatus: StatusReady,
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
Message: "User identity: ready (provided by " + provider + ")",
}
if !verify {
return id
}
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
return externalVerifyFailed(id, "User", provider, err)
}
id.Verified = boolPtr(true)
return id
}
func notProvidedExternally(label, provider string) Identity {
return Identity{
Status: StatusNotConfigured,
Message: label + " identity: not provided by credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
// (open id, user name) already resolved before the probe.
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
id.Available = false
id.Verified = boolPtr(false)
id.Status = StatusVerifyFailed
id.TokenStatus = ""
id.Message = label + " identity: verify failed: " + err.Error()
id.Hint = externalCredentialHint(provider)
return id
}
// externalCredentialHint reports the constraint, not a remediation: the
// identity is the provider's to manage, not lark-cli's to fix. What to do about
// it is the caller's call — there may be no user to ask.
func externalCredentialHint(provider string) string {
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{

View File

@@ -10,9 +10,11 @@ import (
"testing"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
// external-credential diagnosis path. account makes the provider "active";
// token (when set) satisfies ResolveToken during verify.
type fakeExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return p.token, nil
}
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
cred := credential.NewCredentialProvider(
[]extcred.Provider{prov}, nil, nil,
func() (*http.Client, error) { return nil, nil },
)
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{},
}
}
// assertExternalHint locks the contract that an external-provider hint never
// points at interactive commands blocked under an external provider.
func assertExternalHint(t *testing.T, hint string) {
t.Helper()
if hint == "" {
t.Fatalf("hint empty, want external guidance")
}
for _, blocked := range []string{"auth login", "config --help"} {
if strings.Contains(hint, blocked) {
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
}
}
if !strings.Contains(hint, "external") {
t.Fatalf("hint %q should explain credentials are external", hint)
}
}
func TestDiagnose_External_UserReady(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
// The bug this guards: the built-in path read the keychain (empty under an
// external provider) and reported the user as missing. Now availability
// follows the resolved account, so a signed-in user reads as ready.
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
t.Fatalf("user identity = %#v", got.User)
}
if got.User.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
}
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
}
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Available || got.User.Status != StatusMissing {
t.Fatalf("user = %#v, want missing/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_BotOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
// Provider declares bot-only: user is unavailable even though an open id is
// present, and the hint is external (not "auth login").
if got.User.Available || got.User.Status != StatusNotConfigured {
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_UserOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.User.Available || got.User.Status != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
}
assertExternalHint(t, got.Bot.Hint)
}
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user = %#v, want available and verified", got.User)
}
}
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Available || got.User.Status != StatusVerifyFailed {
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("verified = %v, want false", got.User.Verified)
}
assertExternalHint(t, got.User.Hint)
}

View File

@@ -52,6 +52,9 @@ func isPlaceholderValue(value string) bool {
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
printfPlaceholderValue(normalized) ||
htmlEntityAnglePlaceholder(normalized) ||
starMaskedPlaceholder(normalized) ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
@@ -61,9 +64,28 @@ func isPlaceholderValue(value string) bool {
return namedPlaceholderValue(normalized)
}
func htmlEntityAnglePlaceholder(value string) bool {
if !strings.HasPrefix(value, "&lt;") || !strings.HasSuffix(value, "&gt;") {
return false
}
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "&lt;"), "&gt;"))
}
func starMaskedPlaceholder(value string) bool {
var stars int
for _, r := range value {
if r == '*' {
stars++
continue
}
return false
}
return stars >= 3
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
return true
}
return strings.Contains(value, "cli_example") ||
@@ -71,6 +93,15 @@ func namedPlaceholderValue(value string) bool {
conventionalNamedPlaceholderValue(value)
}
func printfPlaceholderValue(value string) bool {
switch value {
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
return true
default:
return false
}
}
func allXPlaceholder(value string) bool {
if len(value) < 4 {
return false

View File

@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -54,8 +55,9 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
@@ -77,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
if isPlaceholderCredentialURL(file, match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
@@ -129,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -266,7 +274,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
return tokenLikePlaceholderValue(key, value)
default:
return false
}
@@ -278,12 +286,16 @@ func tokenLikePlaceholderKey(key string) bool {
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
@@ -293,6 +305,149 @@ func tokenLikePlaceholderValue(value string) bool {
strings.HasPrefix(normalized, ".")
}
func maskedTokenFixturePlaceholderValue(key, value string) bool {
if authCredentialTokenKey(key) {
return false
}
var stars, alnum int
for _, r := range value {
switch {
case r == '*':
stars++
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
alnum++
default:
return false
}
}
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"api_token",
"bot_token",
"refresh_token",
"secret_token",
"session_token",
"service_token",
"bearer_token",
"auth_token",
"authorization_token",
"id_token":
return true
default:
return false
}
}
func isPermissionScopeIdentifierAssignment(key, value string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
switch strings.ToLower(strings.Trim(value, `"',;`)) {
case "read", "write", "modify", "readonly", "get_as_user":
return true
default:
return false
}
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
@@ -333,20 +488,87 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
if !sourceCodeFile(file) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
}
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
return true
}
if strings.Contains(match, "+") {
return true
}
if rawValueQuoted {
return false
}
if quotedLiteral(value) {
return sourceCodeLiteralLooksNonSecret(value, false)
}
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
assignmentIdx := strings.Index(typeAndRHS, "=")
if assignmentIdx < 0 {
return "", false
}
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ",;")
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
return true
}
if credentialShapedValue(value) {
return false
}
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
return true
}
if quotedLiteral(value) {
return false
}
return codeReferenceExpression(value)
}
func credentialAssignmentRawValueQuoted(match string) bool {
key, ok := credentialAssignmentKey(match)
if !ok {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".py":
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
return true
default:
return false
@@ -360,7 +582,147 @@ func quotedLiteral(value string) bool {
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
literal := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(literal, "/") {
return true
}
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
sourceCodeEnvVarNameLiteral(literal) ||
sourceCodeAttributeNameLiteral(literal) ||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
}
func sourceCodeFormatArgumentContext(line, match string) bool {
idx := strings.Index(line, match)
if idx < 0 {
return false
}
prefix := line[:idx]
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
prefix = prefix[semicolon+1:]
}
return strings.Contains(prefix, "fmt.") ||
strings.Contains(prefix, "log.") ||
strings.Contains(prefix, "printf(") ||
strings.Contains(prefix, "Printf(") ||
strings.Contains(prefix, "Errorf(") ||
strings.Contains(prefix, "Fprintf(")
}
func sourceCodeFormatStringLiteral(value string) bool {
for i := 0; i < len(value)-1; i++ {
if value[i] != '%' {
continue
}
if value[i+1] == '%' {
i++
continue
}
j := i + 1
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
j++
}
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
return true
}
}
return false
}
func sourceCodeEnvVarNameLiteral(value string) bool {
if value == "" || !strings.Contains(value, "_") {
return false
}
var hasCredentialMarker bool
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_':
default:
return false
}
}
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
if strings.Contains(value, marker) {
hasCredentialMarker = true
break
}
}
return hasCredentialMarker
}
func sourceCodeAttributeNameLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
}
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "fake_") ||
strings.HasPrefix(normalized, "fake-") ||
strings.Contains(normalized, "placeholder") ||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
}
func sourceCodeCredentialTermLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
return conventionalCredentialPlaceholderName(normalized)
}
func sourceCodeCredentialPrefixLiteral(value string) bool {
switch strings.ToLower(value) {
case "appsecret:":
return true
default:
return false
}
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
return true
default:
return false
}
}
func sourceCodeSchemaTypeLiteral(value string) bool {
normalized := strings.ToLower(value)
return normalized == "string" || strings.HasPrefix(normalized, "string(")
}
func benignCredentialStatusLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
if !delimitedPlaceholderIdentifier(normalized) {
return false
}
for _, marker := range []string{
"bad_fmt",
"expired",
"format",
"invalid",
"missing",
"permission",
"status",
"type",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func codeReferenceExpression(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ";")
if value == "" {
return false
}
@@ -369,7 +731,10 @@ func codeReferenceExpression(value string) bool {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
if !codeIdentifier(value) {
return false
}
return codeIdentifier(value)
}
func codeIdentifier(value string) bool {
@@ -386,16 +751,6 @@ func codeIdentifier(value string) bool {
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
@@ -597,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
func isPlaceholderCredentialURL(file, raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -606,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -620,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {

View File

@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
}
}
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
if len(benign) != 0 {
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
for _, item := range got {
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
}
}
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
}
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
@@ -770,6 +885,172 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
}
}
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
"class Counter:",
" def __init__(self) -> None:",
" self._token_kind: TokenKind | None = None",
" self.access_token: AccessToken | None = None",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
`return fmt.Errorf("failed to remove token: %v", err)`,
`const LarkErrTokenMissing = "token_missing"`,
`const LarkErrTokenExpired = 99991677`,
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
`const LargeAttachmentTokenAttr = "data-mail-token"`,
`const fakeOfficeTokenPrefix = "fake_office_"`,
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
`tokenTypeHint := "access_token"`,
`const TokenTenant Token = "tenant"`,
`const secretKeyPrefix = "appsecret:"`,
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
`return &credential.TokenResult{Token: "test-token"}, nil`,
`fmt.Fprintf(w, "password=%s\n", pat)`,
`text += "(img_token:" + imgToken + ")"`,
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
`this.token = token;`,
`// AppSecret: "appsecret:<appId>"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
`{"token":"&lt;wiki_token&gt;"}`,
`{"token":"Pgrrwvr***********UnRb"}`,
`"scope_name": "auth:user_access_token:read"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
"LARKSUITE_CLI_APP_SECRET=dry-run",
"client_secret: dry_run",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
cases := []struct {
name string
file string
text string
}{
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ScanFile(tc.file, []byte(tc.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("typed credential assignment should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
"client_secret=%s",
"access_token=%v",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
`<img token="..." url="https://..." width="..." height="..."/>`,
@@ -886,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -42,7 +42,7 @@ var AppsDBTableList = common.Shortcut{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err

View File

@@ -89,6 +89,18 @@ func TestDryRunFieldOps(t *testing.T) {
)
assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields")
arrayRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"json": `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
},
nil,
nil,
)
assertDryRunContains(t, dryRunFieldCreate(ctx, arrayRT), `"name":"A"`, `"name":"B"`)
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")

View File

@@ -830,11 +830,6 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
shortcut common.Shortcut
args []string
}{
{
name: "field create",
shortcut: BaseFieldCreate,
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "field update",
shortcut: BaseFieldUpdate,
@@ -1102,6 +1097,54 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
}
})
t.Run("create array sequentially", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
factory, stdout, reg := newExecuteFactory(t)
firstStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"A"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"},
},
}
secondStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"B"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"},
},
}
reg.Register(firstStub)
reg.Register(secondStub)
err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true || data["total"] != float64(2) {
t.Fatalf("unexpected output: %#v", data)
}
fields, _ := data["fields"].([]interface{})
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -1060,6 +1060,15 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil {
t.Fatalf("array create validate err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"formula"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}

View File

@@ -6,10 +6,13 @@ package base
import (
"context"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
)
var fieldCreateBatchDelay = time.Second
func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
@@ -33,12 +36,14 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
Body(body).
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
for _, body := range bodies {
dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body)
}
return dr
}
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -95,11 +100,16 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
}
func validateFieldCreate(runtime *common.RuntimeContext) error {
body, err := validateFieldJSON(runtime)
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
if err != nil {
return err
}
return validateFormulaLookupGuideAck(runtime, "+field-create", body)
for _, body := range bodies {
if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil {
return err
}
}
return nil
}
func validateFieldUpdate(runtime *common.RuntimeContext) error {
@@ -140,19 +150,40 @@ func executeFieldGet(runtime *common.RuntimeContext) error {
}
func executeFieldCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
if err != nil {
return err
fields := make([]interface{}, 0, len(bodies))
for idx, body := range bodies {
if idx > 0 && fieldCreateBatchDelay > 0 {
time.Sleep(fieldCreateBatchDelay)
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
if err != nil {
return err
}
fields = append(fields, data)
}
runtime.Out(map[string]interface{}{"field": data, "created": true}, nil)
if len(fields) == 1 {
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
return nil
}
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
return nil
}
func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) {
bodies, err := parseObjectList(pc, raw, "json")
if err != nil {
return nil, err
}
if len(bodies) == 0 {
return nil, baseFlagErrorf("--json must contain at least one field JSON object")
}
return bodies, nil
}
func executeFieldUpdate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
baseToken := runtime.Str("base-token")

View File

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

View File

@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("title") && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
return err
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
)
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("content") != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
desc := "OpenAPI: create document"
if runtime.IsBot() {
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
}
func buildCreateContent(runtime *common.RuntimeContext) string {
content := runtime.Str("content")
return buildCreateContentWithBody(runtime, runtime.Str("content"))
}
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
title := strings.TrimSpace(runtime.Str("title"))
if title == "" {
return content

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
return err
}
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}

View File

@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
if _, ok := got["return_html5_block_data"]; ok {
t.Fatalf("extra_param should not request html5 block data: %#v", got)
if got["return_html5_block_data"] != true {
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
if len(got) != 1 {
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
if len(got) != 2 {
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
}
}
@@ -579,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
}
}
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchIMMarkdownFence",
"revision_id": float64(1),
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchIMMarkdownFence",
"--doc-format", "im-markdown",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
if errField, ok := envelope["error"]; ok {
t.Fatalf("fetch output should not contain error: %#v", errField)
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
content, _ := doc["content"].(string)
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
}
if _, ok := doc["reference_map"]; ok {
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()

View File

@@ -5,6 +5,7 @@ package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
@@ -63,6 +64,39 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"content": `<html5-block path="@missing.html"></html5-block>`,
})
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err == nil {
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "path" {
t.Fatalf("param = %q, want path", validationErr.Param)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
}
}
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()

View File

@@ -24,7 +24,9 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
@@ -115,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
if content != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body, _ := buildUpdateBodyWithReferenceMap(runtime)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
@@ -134,7 +143,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body, err := buildUpdateBodyWithReferenceMap(runtime)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}

View File

@@ -0,0 +1,696 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const (
html5BlockTag = "html5-block"
html5BlockPathAttr = "path"
html5BlockDataRefAttr = "data-ref"
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
)
type html5BlockReferenceEntry struct {
Data string `json:"data,omitempty"`
Path string `json:"path,omitempty"`
UserID string `json:"user_id,omitempty"`
}
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
type docsV2WriteInput struct {
Content string
ReferenceMap map[string]interface{}
}
type html5BlockAttr struct {
Name string
Value string
}
type html5BlockStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
return body, nil
}
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
body["content"] = buildCreateContentWithBody(runtime, input.Content)
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildUpdateBody(runtime)
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
if input.Content != "" {
body["content"] = input.Content
}
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
}
return nil
}
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
input := docsV2WriteInput{Content: runtime.Str("content")}
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
refMap, err := parseReferenceMapObject(raw, "--reference-map")
if err != nil {
return docsV2WriteInput{}, err
}
input.ReferenceMap = refMap
}
return prepareDocsV2WriteInput(runtime, input)
}
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
refMap := cloneReferenceMapObject(input.ReferenceMap)
html5RefMap, err := html5ReferenceMapFromObject(refMap)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
return docsV2WriteInput{}, err
}
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
return docsV2WriteInput{
Content: content,
ReferenceMap: refMap,
}, nil
}
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
return nil, nil
}
var refMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return refMap, nil
}
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
return nil, nil
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(raw, &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return compactReferenceMap(refMap), nil
}
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
if !strings.Contains(content, "<html5-block") {
return content, compactReferenceMap(refMap), nil
}
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
return "", nil, err
}
refMap = cloneReferenceMap(refMap)
if refMap == nil {
refMap = html5BlockReferenceMap{}
}
ensureReferenceGroup(refMap, html5BlockTag)
nextRef := nextHTML5BlockRef(refMap)
rewrite := func(segment string) (string, error) {
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, err := parseHTML5BlockStartTag(raw)
if err != nil {
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
}
if tag.hasAttr(html5BlockDataAttr) {
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
}
pathValue, hasPath := tag.attr(html5BlockPathAttr)
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
if hasPath && hasDataRef {
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
}
if hasDataRef {
ref := strings.TrimSpace(dataRef)
if ref == "" {
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
}
if _, ok := refMap[html5BlockTag][ref]; !ok {
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
}
return tag.render(false), nil
}
if !hasPath {
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
}
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
if err != nil {
return "", err
}
ref := nextRef()
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
return tag.render(false), nil
})
}
var (
out string
err error
)
if strings.TrimSpace(format) == "markdown" {
out = applyOutsideCodeFences(content, func(segment string) string {
if err != nil {
return segment
}
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
err = rewriteErr
return segment
}
return outSegment
})
} else {
out, err = rewrite(content)
}
if err != nil {
return "", nil, err
}
return out, compactReferenceMap(refMap), nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
for _, match := range matches {
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
continue
}
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
}
}
return nil
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return nil
}
content, _ := doc["content"].(string)
if !hasProcessableHTML5Block(format, content) {
return nil
}
refMap, err := referenceMapFromDocument(doc)
if err != nil {
return err
}
group := refMap[html5BlockTag]
if group == nil {
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
}
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
return err
}
changed := false
for ref, entry := range group {
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
continue
}
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
if err != nil {
return err
}
entry.Data = ""
entry.Path = "@" + filepath.ToSlash(relPath)
group[ref] = entry
changed = true
}
if changed {
doc["reference_map"] = refMap
}
return nil
}
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
raw, ok := doc["reference_map"]
if !ok || raw == nil {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
refMap, err := referenceMapFromValue(raw, "document.reference_map")
if err != nil {
return nil, err
}
if len(refMap) == 0 {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
return refMap, nil
}
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
if typed, ok := value.(html5BlockReferenceMap); ok {
return compactReferenceMap(typed), nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
}
return parseHTML5BlockReferenceMapBytes(raw, label)
}
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
validateSegment := func(segment string) error {
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, parseErr := parseHTML5BlockStartTag(raw)
if parseErr != nil {
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
}
ref, ok := tag.attr(html5BlockDataRefAttr)
if !ok || strings.TrimSpace(ref) == "" {
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
}
ref = strings.TrimSpace(ref)
if _, ok := refMap[html5BlockTag][ref]; !ok {
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
}
return raw, nil
})
return err
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
for typ, group := range refMap {
for ref, entry := range group {
if strings.TrimSpace(entry.Path) == "" {
continue
}
if entry.Data != "" {
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
}
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
if err != nil {
return err
}
entry.Data = data
entry.Path = ""
group[ref] = entry
}
}
return nil
}
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
}
if strings.ToLower(filepath.Ext(clean)) != ".html" {
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
}
return string(data), nil
}
func hasProcessableHTML5Block(format string, content string) bool {
if !strings.Contains(content, "<html5-block") {
return false
}
if strings.TrimSpace(format) != "markdown" {
return true
}
found := false
_ = applyOutsideCodeFences(content, func(segment string) string {
if strings.Contains(segment, "<html5-block") {
found = true
}
return segment
})
return found
}
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
var out strings.Builder
var segment strings.Builder
inFence := false
flush := func() {
if segment.Len() == 0 {
return
}
out.WriteString(fn(segment.String()))
segment.Reset()
}
for _, line := range strings.SplitAfter(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
if !inFence {
flush()
inFence = true
} else {
inFence = false
}
out.WriteString(line)
continue
}
if inFence {
out.WriteString(line)
} else {
segment.WriteString(line)
}
}
flush()
return out.String()
}
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
for ref, entry := range group {
outGroup[ref] = entry
}
out[typ] = outGroup
}
return out
}
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
if len(refMap) == 0 {
return nil
}
out := make(map[string]interface{}, len(refMap))
for key, value := range refMap {
out[key] = value
}
return out
}
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
if len(refMap) == 0 {
return nil, nil
}
group, ok := refMap[html5BlockTag]
if !ok || group == nil {
return nil, nil
}
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
}
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
group := html5RefMap[html5BlockTag]
if len(group) == 0 {
return refMap
}
if refMap == nil {
refMap = map[string]interface{}{}
}
refMap[html5BlockTag] = group
return refMap
}
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
out[typ] = group
}
if len(out) == 0 {
return nil
}
return out
}
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
if refMap[typ] == nil {
refMap[typ] = map[string]html5BlockReferenceEntry{}
}
}
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
next := 1
return func() string {
for {
ref := fmt.Sprintf("html5_%d", next)
next++
if _, exists := refMap[html5BlockTag][ref]; !exists {
return ref
}
}
}
}
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
if !isSafeHTML5BlockResourceName(docToken) {
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
}
if !isSafeHTML5BlockResourceName(ref) {
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
}
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
data := []byte(html)
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
ContentType: "text/html; charset=utf-8",
ContentLength: int64(len(data)),
}, bytes.NewReader(data))
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
}
return relPath, nil
}
func isSafeHTML5BlockResourceName(name string) bool {
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
}
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
var rewriteErr error
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
if rewriteErr != nil {
return raw
}
rewritten, err := fn(raw)
if err != nil {
rewriteErr = err
return raw
}
return rewritten
})
if rewriteErr != nil {
return "", rewriteErr
}
return out, nil
}
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return html5BlockStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != html5BlockTag {
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
}
func (t *html5BlockStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(html5BlockTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
if t.SelfClosing && !selfClosing {
b.WriteString("</")
b.WriteString(html5BlockTag)
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
case '\'':
b.WriteString("&apos;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,563 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
flag := findDocsTestFlag(flags, "reference-map")
if flag.Name == "" {
t.Fatal("reference-map flag not found")
}
if flag.Hidden {
t.Fatal("reference-map flag should be public")
}
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if !strings.Contains(flag.Desc, "@reference-map.json") {
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
}
})
}
}
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
for _, flag := range flags {
if flag.Name == "input" {
t.Fatalf("%s should not expose input flag", name)
}
}
})
}
}
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"command": "append",
"content": `<p><widget data-ref="r1"></widget></p>`,
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
})
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
}
refMap, ok := body["reference_map"].(map[string]interface{})
if !ok {
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
}
widget, _ := refMap["widget"].(map[string]interface{})
r1, _ := widget["r1"].(map[string]interface{})
if got := r1["label"]; got != "widget-ref-value" {
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten with data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := body["resources"]; ok {
t.Fatalf("request body must not use resources: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
return flag
}
}
return common.Flag{}
}
func hasDocsTestInput(flag common.Flag, input string) bool {
for _, item := range flag.Input {
if item == input {
return true
}
}
return false
}
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"new_blocks": []interface{}{
map[string]interface{}{
"block_type": "html5-block",
"block_id": "blk_html5",
"block_token": "boardXXXX",
},
},
},
"result": "success",
})
err := mountAndRunDocs(t, DocsUpdate, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcn_doc",
"--command", "append",
"--content", `<html5-block path="@widget.html"></html5-block>`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
t.Fatalf("reference_map html data = %q", got)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
}
}
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
},
},
},
"tips": "must_read_html_code",
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
if _, err := os.Stat(written); err == nil {
t.Fatalf("small html should stay inline, got file %s", written)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content should keep data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := doc["resources"]; ok {
t.Fatalf("fetch output must not use resources: %#v", doc)
}
if _, ok := data["suggestions"]; ok {
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
}
if got := data["tips"]; got != "must_read_html_code" {
t.Fatalf("tips should be preserved from service response, got %#v", got)
}
}
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": largeHTML},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
raw, err := os.ReadFile(written)
if err != nil {
t.Fatalf("ReadFile(%s) error: %v", written, err)
}
if string(raw) != largeHTML {
t.Fatalf("materialized html = %q", raw)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
t.Fatalf("content should keep data-ref and not path: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
entry := refMap[html5BlockTag]["html5_1"]
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
t.Fatalf("large html should be represented as path, got %#v", entry)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
t.Fatalf("WriteFile(reference-map.json) error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", "@reference-map.json",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
t.Fatalf("expected internal data attr error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@missing.html"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
t.Fatalf("expected path read error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
t.Fatalf("expected inline content error, got: %v", err)
}
}
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html></html>"},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
for _, fence := range []string{"```", "~~~"} {
t.Run(fence, func(t *testing.T) {
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
if hasProcessableHTML5Block("markdown", content) {
t.Fatalf("html5-block inside markdown code fence should be ignored")
}
})
}
}
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
runtime := newFetchShortcutTestRuntime(t, "", nil)
tests := []struct {
name string
docToken string
ref string
want string
}{
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
}
})
}
}
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: method,
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
}
reg.Register(stub)
return stub
}
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
t.Fatalf("decode request body: %v\n%s", err, raw)
}
return body
}
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
t.Helper()
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(data, &refMap); err != nil {
t.Fatalf("decode reference_map: %v\n%s", err, data)
}
return refMap
}

View File

@@ -37,11 +37,16 @@ const (
)
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
Subtype string `json:"subtype,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
}
type drivePullTarget struct {
@@ -189,6 +194,9 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
if drivePullHasTerminalFailure(items) {
break
}
targetFile := remoteFiles[rel]
downloadToken := targetFile.DownloadToken
itemFileToken := targetFile.ItemFileToken
@@ -204,13 +212,9 @@ var DrivePull = common.Shortcut{
// pre-existing file under --if-exists=skip silently
// hides the conflict. Surface as a failure.
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: itemFileToken,
SourceID: itemSourceID,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
conflictErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "local path is a directory, remote is a regular file: %s", target)
item, _ := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "local", conflictErr)
items = append(items, item)
failed++
downloadFailed++
continue
@@ -223,9 +227,14 @@ var DrivePull = common.Shortcut{
}
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
item, terminal := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "download", err)
items = append(items, item)
failed++
downloadFailed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
break
}
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
@@ -251,7 +260,8 @@ var DrivePull = common.Shortcut{
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
item, _ := drivePullFailedItem(absPath, "", "", "delete_failed", "delete_local", relErr)
items = append(items, item)
failed++
continue
}
@@ -271,7 +281,9 @@ var DrivePull = common.Shortcut{
// acceptable here. Shortcuts cannot import internal/vfs
// directly (depguard rule shortcuts-no-vfs).
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
deleteErr := errs.NewInternalError(errs.SubtypeFileIO, "delete local %q: %s", rel, err).WithCause(err)
item, _ := drivePullFailedItem(rel, "", "", "delete_failed", "delete_local", deleteErr)
items = append(items, item)
failed++
continue
}
@@ -286,6 +298,7 @@ var DrivePull = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
"aborted": drivePullHasTerminalFailure(items),
},
"items": items,
}
@@ -317,6 +330,32 @@ var DrivePull = common.Shortcut{
},
}
func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err error) (drivePullItem, bool) {
decision := driveClassifyBatchFailure(err)
item := drivePullItem{
RelPath: relPath,
FileToken: fileToken,
SourceID: sourceID,
Action: action,
Error: err.Error(),
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
Subtype: decision.Subtype,
Retryable: driveBoolPtr(decision.Retryable),
}
return item, decision.Terminal
}
func drivePullHasTerminalFailure(items []drivePullItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {

View File

@@ -1032,6 +1032,66 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
}
}
func TestDrivePullAbortsAfterDownloadForbidden(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "download" || item["error_class"] != "permission_denied" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(http.StatusForbidden) || item["retryable"] != false {
t.Fatalf("unexpected failure classification: %#v", item)
}
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
}
}
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
// regression for the "link/.." escape applied to --delete-local — the
// most dangerous variant, since the bug would otherwise let the kernel

View File

@@ -29,12 +29,25 @@ const (
)
type drivePushItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
Subtype string `json:"subtype,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
}
type driveBatchFailureDecision struct {
Class string
Code int
Subtype string
Retryable bool
Terminal bool
}
// DrivePush is a one-way, file-level mirror from a local directory onto a
@@ -248,9 +261,14 @@ var DrivePush = common.Shortcut{
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
item, terminal := drivePushFailedItem(relDir, "", "failed", "create_folder", 0, ensureErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue
}
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
@@ -266,6 +284,9 @@ var DrivePush = common.Shortcut{
for _, rel := range localPaths {
localFile := localFiles[rel]
if uploadFailed && drivePushHasTerminalFailure(items) {
break
}
if entry, ok := remoteFiles[rel]; ok {
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
@@ -275,9 +296,14 @@ var DrivePush = common.Shortcut{
}
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
if parentErr != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()})
item, terminal := drivePushFailedItem(rel, entry.FileToken, "failed", "create_folder", localFile.Size, parentErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
break
}
continue
}
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
@@ -301,9 +327,14 @@ var DrivePush = common.Shortcut{
if failedToken == "" {
failedToken = entry.FileToken
}
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
item, terminal := drivePushFailedItem(rel, failedToken, "failed", "upload", localFile.Size, upErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
@@ -314,16 +345,26 @@ var DrivePush = common.Shortcut{
parentRel := drivePushParentRel(rel)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
item, terminal := drivePushFailedItem(rel, "", "failed", "create_folder", localFile.Size, ensureErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
item, terminal := drivePushFailedItem(rel, "", "failed", "upload", localFile.Size, upErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
@@ -350,7 +391,11 @@ var DrivePush = common.Shortcut{
}
sort.Strings(remoteRelPaths)
abortDelete := false
for _, rel := range remoteRelPaths {
if abortDelete {
break
}
keepToken := ""
if _, ok := localFiles[rel]; ok {
if chosen, ok := remoteFiles[rel]; ok {
@@ -362,8 +407,14 @@ var DrivePush = common.Shortcut{
continue
}
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
abortDelete = true
break
}
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
@@ -378,6 +429,7 @@ var DrivePush = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
"aborted": drivePushHasTerminalFailure(items),
},
"items": items,
}
@@ -507,6 +559,91 @@ func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemo
return cmp >= 0
}
func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int64, err error) (drivePushItem, bool) {
decision := driveClassifyBatchFailure(err)
item := drivePushItem{
RelPath: relPath,
FileToken: fileToken,
Action: action,
SizeBytes: sizeBytes,
Error: err.Error(),
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
Subtype: decision.Subtype,
Retryable: driveBoolPtr(decision.Retryable),
}
return item, decision.Terminal
}
func driveBoolPtr(v bool) *bool {
return &v
}
func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
decision := driveBatchFailureDecision{Class: "unknown", Retryable: errs.IsRetryable(err)}
problem, ok := errs.ProblemOf(err)
if !ok {
return decision
}
decision.Code = problem.Code
decision.Subtype = string(problem.Subtype)
decision.Retryable = problem.Retryable
switch {
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991672:
decision.Class = "app_scope_missing"
decision.Terminal = true
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991679:
decision.Class = "user_scope_missing"
decision.Terminal = true
case problem.Category == errs.CategoryAuthorization && problem.Subtype == errs.SubtypePermissionDenied:
decision.Class = "permission_denied"
decision.Terminal = true
case problem.Category == errs.CategoryNetwork && problem.Code == http.StatusForbidden:
decision.Class = "permission_denied"
decision.Terminal = true
case problem.Subtype == errs.SubtypeInvalidParameters || problem.Code == 1061002:
decision.Class = "invalid_api_parameters"
decision.Terminal = true
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:
decision.Class = "upload_size_mismatch"
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
decision.Class = "remote_not_found"
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
decision.Class = "server_error"
decision.Terminal = true
case problem.Subtype == errs.SubtypeFailedPrecondition:
decision.Class = "local_file_changed"
default:
decision.Class = string(problem.Subtype)
}
return decision
}
func drivePushHasTerminalFailure(items []drivePushItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
func driveTerminalBatchErrorClass(errorClass string) bool {
switch errorClass {
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
return true
default:
return false
}
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
@@ -600,6 +737,12 @@ func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeCont
// the three-step prepare/part/finish flow, which mirrors drive +upload's
// existing multipart logic.
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
if err := drivePushValidateUploadRequest(file, existingToken, parentToken); err != nil {
return "", "", err
}
if err := drivePushVerifyLocalSnapshot(runtime, file); err != nil {
return "", "", err
}
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
// Multipart finish does not return version on the existing
@@ -612,6 +755,44 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
}
func drivePushValidateUploadRequest(file drivePushLocalFile, existingToken, parentToken string) error {
if strings.TrimSpace(file.FileName) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file name is empty", file.RelPath)
}
if file.Size < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file size is negative", file.RelPath)
}
if strings.TrimSpace(parentToken) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: parent folder token is empty", file.RelPath)
}
if err := validate.ResourceName(parentToken, "parent_node"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: %s", file.RelPath, err)
}
if existingToken != "" {
if err := validate.ResourceName(existingToken, "file_token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot overwrite %q: %s", file.RelPath, err)
}
}
return nil
}
func drivePushVerifyLocalSnapshot(runtime *common.RuntimeContext, file drivePushLocalFile) error {
info, err := runtime.FileIO().Stat(file.OpenPath)
if err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer readable: %v", file.RelPath, err).WithCause(err)
}
if !info.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer a regular file", file.RelPath)
}
if info.Size() != file.Size {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot size no longer matches", file.RelPath)
}
if modTimer, ok := info.(interface{ ModTime() time.Time }); ok && !modTimer.ModTime().Equal(file.ModTime) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot modtime no longer matches", file.RelPath)
}
return nil
}
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {

View File

@@ -5,8 +5,10 @@ package drive
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
@@ -14,12 +16,14 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// countingOpenProvider wraps a fileio.Provider and counts FileIO.Open
@@ -652,6 +656,82 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
}
}
func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/tok_a",
Body: map[string]interface{}{
"code": 1061004,
"msg": "forbidden",
},
})
// No DELETE stub for tok_b: terminal delete failure must stop before it.
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %T %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if got := summary["deleted_remote"]; got != float64(0) {
t.Fatalf("summary.deleted_remote = %v, want 0", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["action"] != "delete_failed" || item["phase"] != "delete" || item["error_class"] != "permission_denied" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061004) || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
for _, item := range items {
if item["file_token"] == "tok_b" {
t.Fatalf("terminal delete failure must abort before tok_b, got items=%#v", items)
}
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
@@ -886,21 +966,22 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
}
out := stdout.String()
// summary.failed should reflect the missing version; summary.uploaded
// should not pretend the overwrite succeeded.
if !strings.Contains(out, `"failed": 1`) {
t.Errorf("expected failed=1, got: %s", out)
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1", got)
}
if !strings.Contains(out, "no version") {
t.Errorf("expected error about missing version in items[].error, got: %s", out)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
if got, _ := items[0]["error"].(string); !strings.Contains(got, "no version") {
t.Errorf("items[0].error = %q, want missing-version message", got)
}
// Pin the token-stability contract: the failed item must surface the
// token returned by upload_all (tok_keep_new), NOT the fallback
// entry.FileToken (tok_keep). Without this, a regression that always
// uses entry.FileToken on failure would slip through.
if !strings.Contains(out, `"file_token": "tok_keep_new"`) {
t.Errorf("expected failed item to surface upload_all's returned file_token (tok_keep_new), got: %s", out)
if got := items[0]["file_token"]; got != "tok_keep_new" {
t.Errorf("items[0].file_token = %v, want tok_keep_new", got)
}
}
@@ -962,24 +1043,313 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
}
out := stdout.String()
// Partial failure reports an ok:false result envelope on stdout (not a
// misleading ok:true) while still carrying BOTH the succeeded and failed
// items — consistent with the pre-change payload. The failed side is
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
if !strings.Contains(out, `"ok": false`) {
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
envelope := decodeDrivePushStdout(t, stdout.Bytes())
if envelope.OK {
t.Fatalf("partial failure must emit ok=false; stdout=%s", stdout.String())
}
if !strings.Contains(out, `"failed": 1`) {
t.Errorf("expected failed=1, got: %s", out)
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
// The freshly returned token must be the one in items[].file_token,
// not the stale entry.FileToken (tok_keep_old).
if !strings.Contains(out, `"file_token": "tok_keep_partial"`) {
t.Errorf("expected items[].file_token to surface upload_all's returned token (tok_keep_partial), got: %s", out)
if got := items[0]["file_token"]; got != "tok_keep_partial" {
t.Errorf("items[0].file_token = %v, want tok_keep_partial", got)
}
if strings.Contains(out, `"file_token": "tok_keep_old"`) {
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; got: %s", out)
if got := items[0]["file_token"]; got == "tok_keep_old" {
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; item=%#v", items[0])
}
}
func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
t.Fatalf("WriteFile a: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
t.Fatalf("WriteFile b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 1061002,
"msg": "params error.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "invalid_api_parameters" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061002) || item["subtype"] != "invalid_parameters" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
for _, item := range items {
if item["rel_path"] == "b.txt" {
t.Fatalf("terminal upload params error must abort before b.txt, got items=%#v", items)
}
}
}
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
t.Fatalf("MkdirAll a: %v", err)
}
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
t.Fatalf("MkdirAll b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 99991672,
"msg": "Access denied. One of the following scopes is required: [drive:drive, space:folder:create].",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "app_scope_missing" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(99991672) || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("missing folder-create scope must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "changing.txt")
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
OnMatch: func(req *http.Request) {
if err := os.WriteFile(localPath, []byte("after-change"), 0o644); err != nil {
t.Fatalf("mutate local file: %v", err)
}
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != false {
t.Fatalf("summary.aborted = %v, want false", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["error"].(string); !strings.Contains(got, "local file changed during push") {
t.Fatalf("items[0].error = %q, want local-change message", got)
}
if strings.Contains(stdout.String(), "httpmock: no stub") {
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
}
problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{
RelPath: "missing.txt",
OpenPath: filepath.Join("local", "missing.txt"),
FileName: "missing.txt",
Size: 1,
ModTime: time.Now(),
})
problem, ok := errs.ProblemOf(problemErr)
if !ok {
t.Fatalf("ProblemOf(snapshot error) ok=false, err=%T %v", problemErr, problemErr)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("snapshot error subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("snapshot error category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if errors.Unwrap(problemErr) == nil {
t.Fatalf("snapshot error cause was not preserved")
}
}
func TestDrivePushDetectsSameSizeLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "changing.txt")
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
originalModTime := time.Unix(100, 0)
changedModTime := time.Unix(200, 0)
if err := os.Chtimes(localPath, originalModTime, originalModTime); err != nil {
t.Fatalf("Chtimes original: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
OnMatch: func(req *http.Request) {
if err := os.WriteFile(localPath, []byte("AFTER!"), 0o644); err != nil {
t.Fatalf("mutate local file: %v", err)
}
if err := os.Chtimes(localPath, changedModTime, changedModTime); err != nil {
t.Fatalf("Chtimes changed: %v", err)
}
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "changing.txt" || item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["error"].(string); !strings.Contains(got, "snapshot modtime no longer matches") {
t.Fatalf("items[0].error = %q, want modtime mismatch", got)
}
if strings.Contains(stdout.String(), "httpmock: no stub") {
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
}
}
@@ -1113,6 +1483,32 @@ func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
}
}
type drivePushStdoutEnvelope struct {
OK bool `json:"ok"`
Data struct {
Summary map[string]interface{} `json:"summary"`
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
func decodeDrivePushStdout(t *testing.T, stdout []byte) drivePushStdoutEnvelope {
t.Helper()
var envelope drivePushStdoutEnvelope
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
return envelope
}
func splitDrivePushStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
t.Helper()
envelope := decodeDrivePushStdout(t, stdout)
if envelope.Data.Summary == nil {
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
}
return envelope.Data.Summary, envelope.Data.Items
}
// TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the
// behavior when a local regular file shares its rel_path with a Lark
// native cloud document on Drive (sheet/docx/bitable/...).

View File

@@ -72,10 +72,10 @@ var DriveSearch = common.Shortcut{
Description: "Search Lark docs, Wiki, and spreadsheet files with flat filters (Search v2: doc_wiki/search)",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
@@ -17,6 +18,13 @@ const (
secureLabelUpdateScope = "docs:secure_label:write_only"
)
type secureLabelOperation string
const (
secureLabelOperationList secureLabelOperation = "list"
secureLabelOperationUpdate secureLabelOperation = "update"
)
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
@@ -28,6 +36,9 @@ var DriveSecureLabelList = common.Shortcut{
Scopes: []string{secureLabelReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
},
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
{Name: "page-token", Desc: "pagination token from previous response"},
@@ -53,7 +64,7 @@ var DriveSecureLabelList = common.Shortcut{
nil,
)
if err != nil {
return err
return decorateSecureLabelError(err, secureLabelOperationList)
}
runtime.OutFormat(data, nil, nil)
return nil
@@ -68,13 +79,21 @@ var DriveSecureLabelUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{secureLabelUpdateScope},
AuthTypes: []string{"user"},
Tips: []string{
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
"When updating many files, serialize requests and back off on rate_limit errors.",
},
Flags: []common.Flag{
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
{Name: "label-id", Desc: "secure label ID to set", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
return err
}
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -82,11 +101,15 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive secure label").
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
Params(map[string]interface{}{"type": docType}).
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
Body(map[string]interface{}{"id": labelID}).
Set("file_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -94,14 +117,18 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return err
}
body := map[string]interface{}{"id": labelID}
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
return decorateSecureLabelError(err, secureLabelOperationUpdate)
}
runtime.Out(data, nil)
return nil
@@ -122,3 +149,70 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
}
// normalizeSecureLabelID trims a label id and rejects display names before the
// request reaches Drive, where they otherwise surface as opaque JSON errors.
func normalizeSecureLabelID(raw string) (string, error) {
labelID := strings.TrimSpace(raw)
if labelID == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
WithParam("--label-id")
}
for _, r := range labelID {
if r < '0' || r > '9' {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
WithParam("--label-id").
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
}
}
return labelID, nil
}
// decorateSecureLabelError appends command-aware recovery guidance while
// preserving upstream/classifier hints already attached to the typed error.
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
guidance := secureLabelErrorGuidance(p.Code, operation)
if guidance == "" {
return err
}
if p.Hint == "" {
p.Hint = guidance
} else if !strings.Contains(p.Hint, guidance) {
p.Hint = p.Hint + "; " + guidance
}
return err
}
// secureLabelErrorGuidance returns recovery guidance for secure-label API
// failures whose generic code-level classification needs command context.
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
switch code {
case 99991400:
if operation == secureLabelOperationUpdate {
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
}
return "secure label listing is rate limited; retry later with exponential backoff"
case 1063013:
if operation == secureLabelOperationUpdate {
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
}
case 1063002:
if operation == secureLabelOperationUpdate {
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
}
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
case 1063001, 99992402, 9499:
if operation == secureLabelOperationUpdate {
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
}
return "check secure label list parameters such as --page-size, --page-token, and --lang"
}
return ""
}

View File

@@ -5,9 +5,11 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -90,13 +92,54 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
Status: 429,
Body: map[string]interface{}{
"code": 99991400,
"msg": "rate limit exceeded",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "server says slow down"},
},
},
},
})
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
if !strings.Contains(apiErr.Hint, want) {
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
}
}
if strings.Contains(apiErr.Hint, "updates are rate limited") {
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--label-id", "7217780879644737539",
"--label-id", " 7217780879644737539 ",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
@@ -132,7 +175,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "7217780879644737539",
"--label-id", " 7217780879644737539 ",
"--as", "user",
}, f, stdout)
if err != nil {
@@ -148,7 +191,32 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "Public(D)",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected label id validation error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--label-id" {
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
@@ -169,7 +237,78 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
if err == nil {
t.Fatal("expected 1063013 error")
}
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
t.Fatalf("expected raw API error message, got: %v", err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
}
if !strings.Contains(validationErr.Hint, "approval") {
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 400,
Body: map[string]interface{}{
"code": 9499, "msg": "Invalid parameter type in json: id",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected 9499 error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 429,
Body: map[string]interface{}{
"code": 99991400, "msg": "rate limit exceeded",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "backoff") {
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
}
}

View File

@@ -25,12 +25,21 @@ const (
driveSyncOnConflictAsk = "ask"
)
func driveSyncActionScopes() []string {
return []string{"drive:file:download", "drive:file:upload", "space:folder:create"}
}
type driveSyncItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
Subtype string `json:"subtype,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
}
// DriveSync performs a two-way sync between a local directory and a Drive
@@ -66,6 +75,7 @@ var DriveSync = common.Shortcut{
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
"Actual sync execution pre-flights download, upload, and folder-create scopes before listing or walking, so missing grants fail before any partial sync can start.",
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -110,10 +120,8 @@ var DriveSync = common.Shortcut{
duplicateRemote = driveDuplicateRemoteFail
}
quick := runtime.Bool("quick")
if !quick {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
if err := runtime.EnsureScopes(driveSyncActionScopes()); err != nil {
return err
}
safeRoot, err := validate.SafeInputPath(localDir)
@@ -262,18 +270,6 @@ var DriveSync = common.Shortcut{
var pulled, pushed, skipped, failed int
items := make([]driveSyncItem, 0)
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
if len(plannedUploads) > 0 {
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
return err
}
}
// Build push infrastructure: local walk for push + remote views + folder cache.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
@@ -287,20 +283,18 @@ var DriveSync = common.Shortcut{
return err
}
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
return err
}
}
// Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped.
for _, relDir := range localDirs {
if driveSyncHasTerminalFailure(items) {
break
}
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item)
failed++
continue
}
@@ -310,6 +304,9 @@ var DriveSync = common.Shortcut{
// 2a. Pull new_remote files.
for _, entry := range newRemote {
if driveSyncHasTerminalFailure(items) {
break
}
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
// Non-file type (doc, shortcut, etc.) — skip.
@@ -317,8 +314,13 @@ var DriveSync = common.Shortcut{
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
@@ -327,6 +329,9 @@ var DriveSync = common.Shortcut{
// 2b. Push new_local files.
for _, entry := range newLocal {
if driveSyncHasTerminalFailure(items) {
break
}
localFile, ok := pushLocalFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
@@ -336,14 +341,20 @@ var DriveSync = common.Shortcut{
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item)
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, token, "failed", "push", "upload", upErr)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
@@ -352,6 +363,9 @@ var DriveSync = common.Shortcut{
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
if driveSyncHasTerminalFailure(items) {
break
}
remoteFile := remoteFiles[entry.RelPath]
localFile, hasLocal := pushLocalFiles[entry.RelPath]
if !hasLocal {
@@ -379,8 +393,13 @@ var DriveSync = common.Shortcut{
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
@@ -396,7 +415,8 @@ var DriveSync = common.Shortcut{
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
items = append(items, item)
failed++
continue
}
@@ -411,8 +431,13 @@ var DriveSync = common.Shortcut{
if failedToken == "" {
failedToken = existingToken
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, failedToken, "failed", "push", "upload", upErr)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
@@ -433,7 +458,8 @@ var DriveSync = common.Shortcut{
}
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
if err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", err)
items = append(items, item)
failed++
continue
}
@@ -441,7 +467,9 @@ var DriveSync = common.Shortcut{
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
renameErr := errs.NewInternalError(errs.SubtypeFileIO, "rename local: %s", err).WithCause(err)
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", renameErr)
items = append(items, item)
failed++
continue
}
@@ -454,19 +482,30 @@ var DriveSync = common.Shortcut{
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
notFoundErr := errs.NewAPIError(errs.SubtypeNotFound, "%s", errMsg)
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "pull", "download", notFoundErr)
items = append(items, item)
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
downloadErr := err
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := err.Error()
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", downloadErr)
if rollbackErr != nil {
item.Error = errMsg
}
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
@@ -492,6 +531,7 @@ var DriveSync = common.Shortcut{
"pushed": pushed,
"skipped": skipped,
"failed": failed,
"aborted": driveSyncHasTerminalFailure(items),
},
"items": items,
}
@@ -520,6 +560,32 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
return remoteFiles
}
func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, err error) (driveSyncItem, bool) {
decision := driveClassifyBatchFailure(err)
item := driveSyncItem{
RelPath: relPath,
FileToken: fileToken,
Action: action,
Direction: direction,
Error: err.Error(),
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
Subtype: decision.Subtype,
Retryable: driveBoolPtr(decision.Retryable),
}
return item, decision.Terminal
}
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the
// user chose to skip.
@@ -558,51 +624,6 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
}
}
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
if len(newRemote) > 0 {
return true
}
for _, entry := range modified {
switch conflictResolutions[entry.RelPath] {
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
return true
}
}
return false
}
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
planned := make([]string, 0, len(newLocal)+len(modified))
for _, entry := range newLocal {
planned = append(planned, entry.RelPath)
}
for _, entry := range modified {
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
planned = append(planned, entry.RelPath)
}
}
return planned
}
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
for _, relPath := range uploadPaths {
parentRel := drivePushParentRel(relPath)
if parentRel == "" {
continue
}
if _, ok := folderCache[parentRel]; !ok {
return true
}
}
// Empty local directories also need create_folder if not already on Drive.
for _, relDir := range localDirs {
if _, ok := folderCache[relDir]; !ok {
return true
}
}
return false
}
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {

View File

@@ -311,6 +311,71 @@ func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) {
}
}
func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) {
syncTestConfig := &core.CliConfig{
AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "100"},
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file", "modified_time": "100"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
})
err := mountAndRunDrive(t, DriveSync, []string{
"+sync",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
assertDriveSyncPartialFailure(t, err)
summary := driveSyncStdoutSummary(t, stdout.Bytes())
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item.RelPath != "a.txt" || item.Direction != "pull" || item.Phase != "download" || item.ErrorClass != "permission_denied" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item.Code != http.StatusForbidden || item.Retryable == nil || *item.Retryable {
t.Fatalf("unexpected failure classification: %#v", item)
}
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
}
}
// TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins
// pushes the local version over the remote file.
func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) {
@@ -1552,11 +1617,11 @@ func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
}
}
func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
syncTestConfig := &core.CliConfig{
AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig)
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
tmpDir := t.TempDir()
@@ -1568,34 +1633,6 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
t.Fatalf("WriteFile a.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("remote-a"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("remote-a"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DriveSync, []string{
"+sync",
"--local-dir", "local",
@@ -1603,11 +1640,30 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
"--on-conflict", "remote-wins",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected exact remote-wins to succeed with download-only scope, got: %v\nstdout: %s", err, stdout.String())
if err == nil {
t.Fatalf("expected action-scope preflight to reject download-only scope\nstdout: %s", stdout.String())
}
if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") {
t.Fatalf("should not surface missing_scope, got: %s", stdout.String())
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if permErr.Subtype != errs.SubtypeMissingScope {
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
}
for _, scope := range []string{"drive:file:upload", "space:folder:create"} {
found := false
for _, missing := range permErr.MissingScopes {
if missing == scope {
found = true
break
}
}
if !found {
t.Fatalf("MissingScopes = %v, want %s", permErr.MissingScopes, scope)
}
}
if strings.Contains(stdout.String(), "folder_root") {
t.Fatalf("preflight should fail before remote listing, got stdout: %s", stdout.String())
}
}
@@ -2552,30 +2608,6 @@ func TestDriveSyncAskConflictRemoteShortForms(t *testing.T) {
}
}
// TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly verifies
// that driveSyncNeedsDownloadScope returns false when there are no
// new_remote entries and all modified entries resolve to local-wins.
func TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly(t *testing.T) {
modified := []driveStatusEntry{{RelPath: "a.txt"}, {RelPath: "b.txt"}}
resolutions := map[string]string{"a.txt": driveSyncOnConflictLocalWins, "b.txt": driveSyncOnConflictLocalWins}
if driveSyncNeedsDownloadScope(nil, modified, resolutions) {
t.Fatal("expected false when no new_remote and all conflicts are local-wins")
}
}
// TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth verifies that
// driveSyncNeedsDownloadScope returns true when a modified entry resolves
// to keep-both (which requires pulling the remote version).
func TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth(t *testing.T) {
modified := []driveStatusEntry{{RelPath: "a.txt"}}
resolutions := map[string]string{"a.txt": driveSyncOnConflictKeepBoth}
if !driveSyncNeedsDownloadScope(nil, modified, resolutions) {
t.Fatal("expected true when a conflict resolves to keep-both")
}
}
// TestDriveSyncRemoteWinsReportsMissingPullView verifies that when a
// modified file's rel_path is not in pullRemoteFiles during the
// remote-wins branch, a failed item is reported instead of a panic.
@@ -3083,3 +3115,19 @@ func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
}
return envelope.Data.Items
}
func driveSyncStdoutSummary(t *testing.T, stdout []byte) map[string]interface{} {
t.Helper()
var envelope struct {
Data struct {
Summary map[string]interface{} `json:"summary"`
} `json:"data"`
}
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
if envelope.Data.Summary == nil {
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
}
return envelope.Data.Summary
}

View File

@@ -3,7 +3,10 @@
package drive
import "testing"
import (
"reflect"
"testing"
)
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
@@ -58,3 +61,12 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
}
}
}
func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
t.Parallel()
want := []string{"user", "bot"}
if !reflect.DeepEqual(DriveSearch.AuthTypes, want) {
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
dr := common.NewDryRunAPI()
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
_, err := runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
}
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil
},
}
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body
}
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{
"minute_token": minuteToken,
"to_user_id": toUserID,
}
if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else {
out["from_user_id"] = fromUserID
}
return out
}
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
if fromSpeakerID != "" {
return fromSpeakerID
}

View File

@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
}
}
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
// straight through without a second speakerlist call. If the code still
// prefetched speakerlist, the unregistered GET would fail the request.
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
FromSpeakerInput string `json:"from_speaker_input"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
}
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
}
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

311
shortcuts/okr/okr_patch.go Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{
for _, item := range filteredItems {
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
outputItem := map[string]interface{}{
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"completed": completed,
}
if createdAtStr, ok := item["created_at"].(string); ok {
if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil {
outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
}
}
if !completedAt.IsZero() {
outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339)
}
if dueObj, ok := item["due"].(map[string]interface{}); ok {
if tsStr, ok := dueObj["timestamp"].(string); ok {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
@@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{
summary, _ := item["summary"].(string)
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
var dueTimeStr string
if dueObj, ok := item["due"].(map[string]interface{}); ok {
@@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{
if urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintf(w, " Completed: %t\n", completed)
if !completedAt.IsZero() {
fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04"))
}
if dueTimeStr != "" {
fmt.Fprintf(w, " Due: %s\n", dueTimeStr)
}
@@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{
return nil
},
}
func taskCompletionState(item map[string]interface{}) (bool, time.Time) {
completedAtStr, _ := item["completed_at"].(string)
if completedAtStr == "" || completedAtStr == "0" {
return false, time.Time{}
}
ts, err := strconv.ParseInt(completedAtStr, 10, 64)
if err != nil {
return false, time.Time{}
}
return true, time.UnixMilli(ts)
}

View File

@@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
}
}
func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`)
for _, expected := range []string{
`"guid": "task-open"`,
`"completed": false`,
`"guid": "task-done"`,
`"completed": true`,
`"completed_at": "` + expectedCompletedAt + `"`,
} {
if !strings.Contains(outNorm, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String())
}
}
}
func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
for _, expected := range []string{
"[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n",
"[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n",
} {
if !strings.Contains(out, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, out)
}
}
if count := strings.Count(out, "Completed At:"); count != 1 {
t.Fatalf("Completed At count = %d, want 1; output: %s", count, out)
}
}
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
// Execute (--created_at / --due-start / --due-end). The parse runs before any
// API call, so a malformed value deterministically surfaces a typed

View File

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

View File

@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
VCMeetingLeave,
VCMeetingListActive,
VCMeetingEvents,
VCMeetingMessageSend,
}
}

View File

@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
meetingMessageTypeText = "text"
meetingMessageTypeReaction = "reaction"
// Keep the client-side cap below the server-side content limit.
meetingMessageMaxTextBytes = 48 * 1024
meetingMessageMaxUUIDBytes = 128
)
// VCMeetingMessageSend sends an in-meeting text message or reaction emoji.
var VCMeetingMessageSend = common.Shortcut{
Service: "vc",
Command: "+meeting-message-send",
Description: "Send an in-meeting text message or reaction emoji",
Risk: "write",
Scopes: []string{"vc:meeting.message:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to send into"},
{Name: "msg-type", Desc: "message type: text or reaction"},
{Name: "text", Desc: "text content when --msg-type text"},
{Name: "emoji-type", Desc: "emoji key when --msg-type reaction, for example LOVE, THUMBSUP, VC_NoSound"},
{Name: "uuid", Desc: "optional idempotency key"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
return err
}
_, err := validateMeetingMessagePayload(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(buildMeetingMessageSendPath()).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintln(w, "Meeting message sent.")
if msgType := common.GetString(data, "msg_type"); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
} else if msgType, _ := body["msg_type"].(string); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
}
if uuid := common.GetString(data, "uuid"); uuid != "" {
fmt.Fprintf(w, " UUID: %s\n", uuid)
}
})
return nil
},
}
func buildMeetingMessageSendPath() string {
return "/open-apis/vc/v1/bots/message"
}
func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
msgType, err := validateMeetingMessagePayload(runtime)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
"msg_type": msgType,
}
switch msgType {
case meetingMessageTypeText:
body["content"] = strings.TrimSpace(runtime.Str("text"))
case meetingMessageTypeReaction:
body["content"] = strings.TrimSpace(runtime.Str("emoji-type"))
}
if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" {
body["uuid"] = uuid
}
return body, nil
}
func validateMeetingMessagePayload(runtime *common.RuntimeContext) (string, error) {
msgType, err := resolveMeetingMessageType(runtime)
if err != nil {
return "", err
}
if msgType == meetingMessageTypeText {
text := strings.TrimSpace(runtime.Str("text"))
if len(text) > meetingMessageMaxTextBytes {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--text is too long; max %d bytes", meetingMessageMaxTextBytes)).WithParam("--text")
}
}
if uuid := strings.TrimSpace(runtime.Str("uuid")); len(uuid) > meetingMessageMaxUUIDBytes {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--uuid is too long; max %d bytes", meetingMessageMaxUUIDBytes)).WithParam("--uuid")
}
return msgType, nil
}
func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) {
msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type")))
text := strings.TrimSpace(runtime.Str("text"))
emojiType := strings.TrimSpace(runtime.Str("emoji-type"))
if msgType == "" {
switch {
case text != "" && emojiType == "":
msgType = meetingMessageTypeText
case text == "" && emojiType != "":
msgType = meetingMessageTypeReaction
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type")
}
}
switch msgType {
case meetingMessageTypeText:
if text == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text")
}
if emojiType != "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type cannot be used when --msg-type text").WithParam("--emoji-type")
}
case meetingMessageTypeReaction:
if emojiType == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type")
}
if text != "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text cannot be used when --msg-type reaction").WithParam("--text")
}
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type")
}
return msgType, nil
}

View File

@@ -0,0 +1,312 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newMeetingMessageSendRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
cmd.Flags().String("msg-type", "", "")
cmd.Flags().String("text", "", "")
cmd.Flags().String("emoji-type", "", "")
cmd.Flags().String("uuid", "", "")
return common.TestNewRuntimeContext(cmd, defaultConfig())
}
func mustSetMeetingMessageSendFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
}
}
func assertMeetingMessageSendValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
}
func TestMeetingMessageSendBuildBody_Text(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ")
mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeText {
t.Fatalf("msg_type = %v, want text", body["msg_type"])
}
if body["content"] != "hello" {
t.Fatalf("content = %v, want hello", body["content"])
}
if body["uuid"] != "cid-1" {
t.Fatalf("uuid = %v, want cid-1", body["uuid"])
}
}
func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeReaction {
t.Fatalf("msg_type = %v, want reaction", body["msg_type"])
}
if body["content"] != "LOVE" {
t.Fatalf("content = %v, want LOVE", body["content"])
}
if _, ok := body["text"]; ok {
t.Fatalf("text should be omitted for reaction, got %#v", body["text"])
}
if _, ok := body["emoji_type"]; ok {
t.Fatalf("emoji_type should be omitted for reaction, got %#v", body["emoji_type"])
}
}
func TestMeetingMessageSendBuildBody_ReactionVCFeedbackKey(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_NoSound")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["content"] != "VC_NoSound" {
t.Fatalf("content = %v, want VC_NoSound", body["content"])
}
}
func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--meeting-id")
if !strings.Contains(err.Error(), "9-digit meeting number") {
t.Fatalf("error = %v, want 9-digit meeting number hint", err)
}
}
func TestMeetingMessageSendValidateRejectsMissingEmojiType(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
if !strings.Contains(err.Error(), "--emoji-type is required") {
t.Fatalf("error = %v, want --emoji-type required", err)
}
}
func TestMeetingMessageSendValidateRejectsTextMessageWithEmojiType(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "text")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
if !strings.Contains(err.Error(), "--emoji-type cannot be used") {
t.Fatalf("error = %v, want --emoji-type conflict", err)
}
}
func TestMeetingMessageSendValidateRejectsReactionMessageWithText(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--text")
if !strings.Contains(err.Error(), "--text cannot be used") {
t.Fatalf("error = %v, want --text conflict", err)
}
}
func TestMeetingMessageSendValidateRejectsLongText(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "text", strings.Repeat("a", meetingMessageMaxTextBytes+1))
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--text")
if !strings.Contains(err.Error(), "--text is too long") {
t.Fatalf("error = %v, want --text too long", err)
}
}
func TestMeetingMessageSendValidateRejectsLongUUID(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
mustSetMeetingMessageSendFlag(t, runtime, "uuid", strings.Repeat("u", meetingMessageMaxUUIDBytes+1))
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--uuid")
if !strings.Contains(err.Error(), "--uuid is too long") {
t.Fatalf("error = %v, want --uuid too long", err)
}
}
func TestMeetingMessageSendDryRun_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--dry-run", "--as", "user",
"--meeting-id", "7651377260537433044",
"--text", "hello",
"--uuid", "cid-1",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/vc/v1/bots/message",
"\"meeting_id\": \"7651377260537433044\"",
"\"msg_type\": \"text\"",
"\"content\": \"hello\"",
"\"uuid\": \"cid-1\"",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}
func TestMeetingMessageSendDryRun_ValidationErrorEnvelope(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
dryRun := VCMeetingMessageSend.DryRun(context.Background(), runtime)
if got := dryRun.Format(); !strings.Contains(got, "--msg-type is required") {
t.Fatalf("dry-run error = %v, want --msg-type required", got)
}
}
func TestMeetingMessageSendExecute_Text(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: buildMeetingMessageSendPath(),
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"msg_type": "text",
"uuid": "cid-1",
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--as", "user",
"--format", "pretty",
"--meeting-id", "7651377260537433044",
"--text", "hello",
"--uuid", "cid-1",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
for key, want := range map[string]string{
"meeting_id": "7651377260537433044",
"msg_type": "text",
"content": "hello",
"uuid": "cid-1",
} {
if req[key] != want {
t.Errorf("%s = %v, want %s", key, req[key], want)
}
}
out := stdout.String()
for _, want := range []string{
"Meeting message sent.",
"Type: text",
"UUID: cid-1",
} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
}
func TestMeetingMessageSendExecute_ReactionFallsBackToRequestType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: buildMeetingMessageSendPath(),
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--as", "user",
"--format", "pretty",
"--meeting-id", "7651377260537433044",
"--msg-type", "reaction",
"--emoji-type", "LOVE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
if out := stdout.String(); !strings.Contains(out, "Type: reaction") {
t.Fatalf("output missing fallback type: %s", out)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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