From 83adbac2b269b5e025430fec9daff6902d7d0e2c Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Tue, 26 May 2026 15:51:00 +0800 Subject: [PATCH 01/62] docs: clarify contributor guidance (#1096) (cherry picked from commit 406e0dee6a8e714acd43f333045456f41925bf3e) Co-authored-by: JulyanXu <1581085037@qq.com> --- .github/pull_request_template.md | 2 +- README.md | 2 ++ README.zh.md | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2ba1ecadc..61013c316 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,7 @@ ## Test Plan - [ ] Unit tests pass -- [ ] Manual local verification confirms the `lark xxx` command works as expected +- [ ] Manual local verification confirms the `lark-cli ` flow works as expected ## Related Issues diff --git a/README.md b/README.md index 56e4dba11..f61910841 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio For major changes, we recommend discussing with us first via an Issue. +Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents. + ## License This project is licensed under the **MIT License**. diff --git a/README.zh.md b/README.zh.md index 82b55305a..d0df8d4e6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -280,6 +280,8 @@ lark-cli schema im.messages.delete 对于较大的改动,建议先通过 Issue 与我们讨论。 +提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。 + ## 许可证 本项目基于 **MIT 许可证** 开源。 From f12d279fc209819d7d29d2bea13ca01bae310a97 Mon Sep 17 00:00:00 2001 From: AlbertSun Date: Tue, 26 May 2026 16:20:33 +0800 Subject: [PATCH 02/62] feat: add config keychain-downgrade subcommand (macOS) (#1085) * feat(config): add command to explicitly dowgrade keychain storage to use file * feat(config): add command to explicitly dowgrade keychain storage to use file * fix(lint): use the corresponding vfs.Xxx() from internal/vfs * fix: optimize scanError && osReadDir * opt: remove CmdConfigKeychainDowngrade wrapper & runF * fix: add downgrade hint on keychain blocked * opt: remove redundant ErrOrphanedCredentials * opt: fix suggested concurrent platformSet issue --- cmd/config/config.go | 1 + cmd/config/keychain_downgrade.go | 73 ++++++ cmd/config/keychain_downgrade_other.go | 28 ++ internal/keychain/keychain.go | 1 + internal/keychain/keychain_darwin.go | 129 +++++++++- internal/keychain/keychain_darwin_test.go | 301 ++++++++++++++++++++++ internal/keychain/keychain_hint_other.go | 10 + 7 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 cmd/config/keychain_downgrade.go create mode 100644 cmd/config/keychain_downgrade_other.go create mode 100644 internal/keychain/keychain_hint_other.go diff --git a/cmd/config/config.go b/cmd/config/config.go index c99f6b482..f3c643fd5 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdConfigStrictMode(f)) cmd.AddCommand(NewCmdConfigPolicy(f)) cmd.AddCommand(NewCmdConfigPlugins(f)) + cmd.AddCommand(NewCmdConfigKeychainDowngrade(f)) return cmd } diff --git a/cmd/config/keychain_downgrade.go b/cmd/config/keychain_downgrade.go new file mode 100644 index 000000000..58c179d21 --- /dev/null +++ b/cmd/config/keychain_downgrade.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins +// the master key to the local file fallback (master.key.file) so subsequent +// operations bypass the OS Keychain. Useful inside sandboxes like Codex +// where the system Keychain is unreachable. +func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "keychain-downgrade", + Short: "Downgrade keychain storage to a local file (macOS only)", + Long: `Materialize the master key from the macOS system Keychain into a local file +under ~/Library/Application Support/lark-cli/master.key.file, then pin all +subsequent reads to that file. + +Intended workflow: run this once from an interactive Terminal session on +macOS (where the system Keychain is reachable). After it finishes, +sandboxed / automation / CI runs of lark-cli on the same machine will read +the master key from the local file and no longer need the OS Keychain. + +This is the supported fix for environments like the Codex sandbox where the +system Keychain is blocked. Running keychain-downgrade from inside such a +sandbox will itself fail with "keychain access blocked" — that is expected; +run it from an interactive macOS session instead. + +The OS Keychain entry is preserved as a cold backup; nothing is deleted there. +The command is idempotent: re-running it on an already-downgraded install +reports "already downgraded" and exits 0.`, + RunE: func(cmd *cobra.Command, args []string) error { + return configKeychainDowngradeRun(f) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func configKeychainDowngradeRun(f *cmdutil.Factory) error { + service := keychain.LarkCliService + keyPath := keychain.MasterKeyFilePath(service) + + result, err := keychain.DowngradeMasterKeyToFile(service) + if err != nil { + return output.ErrWithHint( + output.ExitAPI, + "config", + fmt.Sprintf("keychain downgrade failed: %v", err), + "This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.", + ) + } + + switch result { + case keychain.DowngradeAlreadyDone: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath)) + case keychain.DowngradeUsedKeychainKey: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath)) + case keychain.DowngradeCreatedNewKey: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath)) + } + return nil +} diff --git a/cmd/config/keychain_downgrade_other.go b/cmd/config/keychain_downgrade_other.go new file mode 100644 index 000000000..6255aee43 --- /dev/null +++ b/cmd/config/keychain_downgrade_other.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !darwin + +package config + +import ( + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigKeychainDowngrade is registered on all platforms so that +// `lark-cli config --help` reads the same everywhere. On non-macOS it +// refuses with a clear message. +func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { + _ = f + cmd := &cobra.Command{ + Use: "keychain-downgrade", + Short: "Downgrade keychain storage to a local file (macOS only)", + Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`, + RunE: func(cmd *cobra.Command, args []string) error { + return output.ErrValidation("keychain-downgrade is only supported on macOS") + }, + } + return cmd +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index e2cdecc11..3af04ca50 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -41,6 +41,7 @@ func wrapError(op string, err error) error { if errors.Is(err, errNotInitialized) { hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init." } + hint += extraHint(err) func() { defer func() { recover() }() diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 8e92e2bcc..d92a05560 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -43,6 +43,12 @@ var keyringGet = keyring.Get // keyringSet is overridden in tests to simulate system keychain writes. var keyringSet = keyring.Set +// errKeychainBlocked is returned when the OS Keychain is reachable but +// denies access — sandbox restriction, user-denied prompt, or a 5-second +// timeout (typically caused by an ignored permission dialog). Distinct +// from errNotInitialized (master key entry genuinely absent). +var errKeychainBlocked = errors.New("keychain access blocked") + // StorageDir returns the storage directory for a given service name on macOS. func StorageDir(service string) string { home, err := vfs.UserHomeDir() @@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) { return } else if !errors.Is(err, keyring.ErrNotFound) { // Not ErrNotFound, which means access was denied or blocked by the system - resCh <- result{key: nil, err: errors.New("keychain access blocked")} + resCh <- result{key: nil, err: errKeychainBlocked} return } @@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) { return res.key, res.err case <-ctx.Done(): // Timeout is usually caused by ignored/blocked permission prompts - return nil, errors.New("keychain access blocked") + return nil, errKeychainBlocked } } @@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) { if err != nil { return "", err } - plaintext, err := decryptData(data, key) - if err != nil { - return "", err - } - return plaintext, nil + return decryptData(data, key) } // platformSet stores a value in the macOS keychain. @@ -316,3 +318,116 @@ func platformRemove(service, account string) error { } return nil } + +// DowngradeResult reports what DowngradeMasterKeyToFile did. The command +// never writes to or removes from the OS Keychain — it only reads from it +// and only writes to the local file fallback. +type DowngradeResult int + +const ( + // DowngradeAlreadyDone means master.key.file was already present and valid. + DowngradeAlreadyDone DowngradeResult = iota + // DowngradeUsedKeychainKey means the existing OS Keychain master key was + // copied verbatim into the local file fallback. Existing .enc credentials + // remain readable via the file path. + DowngradeUsedKeychainKey + // DowngradeCreatedNewKey means the OS Keychain held no master key, so a + // fresh random key was generated and written to the file fallback only. + // The OS Keychain was not touched. + DowngradeCreatedNewKey +) + +// MasterKeyFilePath returns the absolute path of the file fallback master key +// for the given service. +func MasterKeyFilePath(service string) string { + return filepath.Join(StorageDir(service), fileMasterKeyName) +} + +// DowngradeMasterKeyToFile materializes the OS Keychain master key into the +// local file fallback so that subsequent platformGet calls take the file-first +// path and bypass the OS Keychain entirely. The Keychain entry itself is kept +// as a cold backup; nothing is removed there. +// +// Idempotent: if master.key.file is already present and valid, returns +// DowngradeAlreadyDone without touching anything. +func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) { + dir := StorageDir(service) + keyPath := filepath.Join(dir, fileMasterKeyName) + + existing, statErr := vfs.ReadFile(keyPath) + if statErr == nil { + if len(existing) == masterKeyBytes { + return DowngradeAlreadyDone, nil + } + return 0, errors.New("keychain is corrupted") + } + if !errors.Is(statErr, os.ErrNotExist) { + return 0, statErr + } + + result := DowngradeUsedKeychainKey + key, err := getMasterKey(service, false) + if err != nil { + if !errors.Is(err, errNotInitialized) { + return 0, err + } + // Keychain has no master key. Generate a fresh one *locally* — do + // NOT call getMasterKey(service, true), which would write the new + // key into the OS Keychain as a side effect. keychain-downgrade + // must never modify the OS Keychain; it only ever reads from it. + key = make([]byte, masterKeyBytes) + if _, err := rand.Read(key); err != nil { + return 0, err + } + result = DowngradeCreatedNewKey + } + + if err := vfs.MkdirAll(dir, 0700); err != nil { + return 0, err + } + file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + concurrent, readErr := vfs.ReadFile(keyPath) + if readErr == nil && len(concurrent) == masterKeyBytes { + return DowngradeAlreadyDone, nil + } + if readErr != nil { + return 0, readErr + } + return 0, errors.New("keychain is corrupted") + } + return 0, err + } + writeFailed := true + defer func() { + if writeFailed { + _ = vfs.Remove(keyPath) + } + }() + if _, err := file.Write(key); err != nil { + _ = file.Close() + return 0, err + } + if err := file.Close(); err != nil { + return 0, err + } + writeFailed = false + return result, nil +} + +// extraHint appends a darwin-specific suggestion to wrapError's hint message +// when the failure is one keychain-downgrade can recover from: either the +// master key is missing (errNotInitialized) or the OS Keychain is reachable +// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout). +// In both cases the user can run keychain-downgrade from an interactive +// Terminal session, after which the file fallback is readable from any +// context (sandbox, automation, CI, etc.). Corruption errors are +// deliberately excluded — downgrade would re-read the same bad bytes and +// fail; the right fix there is to delete the corrupt Keychain entry first. +func extraHint(err error) string { + if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) { + return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)." + } + return "" +} diff --git a/internal/keychain/keychain_darwin_test.go b/internal/keychain/keychain_darwin_test.go index 5dc9ddb9a..3d24ca759 100644 --- a/internal/keychain/keychain_darwin_test.go +++ b/internal/keychain/keychain_darwin_test.go @@ -10,8 +10,10 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" + "github.com/larksuite/cli/internal/output" "github.com/zalando/go-keyring" ) @@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) { } } +// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade +// when master.key.file already exists is a no-op and reports AlreadyDone +// without touching the system keychain. +func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + t.Fatalf("keyringGet should not be called when master.key.file is already valid") + return "", nil + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet should not be called when master.key.file is already valid") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + preExisting := make([]byte, masterKeyBytes) + for i := range preExisting { + preExisting[i] = byte(i + 7) + } + keyPath := filepath.Join(dir, fileMasterKeyName) + if err := os.WriteFile(keyPath, preExisting, 0600); err != nil { + t.Fatalf("WriteFile(master key) error = %v", err) + } + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeAlreadyDone { + t.Fatalf("result = %v, want DowngradeAlreadyDone", result) + } + + after, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytesEqual(after, preExisting) { + t.Fatalf("master.key.file content changed; want preserved") + } +} + +// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain +// key exists, the file does not, and downgrade copies the bytes verbatim +// so that existing .enc files (encrypted with the keychain key) remain +// readable via the file fallback. +func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + keychainKey := make([]byte, masterKeyBytes) + for i := range keychainKey { + keychainKey[i] = byte(i + 11) + } + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return base64.StdEncoding.EncodeToString(keychainKey), nil + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet should not be called when keychain already has a master key") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeUsedKeychainKey { + t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result) + } + + got, err := os.ReadFile(MasterKeyFilePath(service)) + if err != nil { + t.Fatalf("ReadFile(master.key.file) error = %v", err) + } + if !bytesEqual(got, keychainKey) { + t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable") + } +} + +// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user" +// path: keychain is empty and no .enc files exist, so we generate a new +// random key and write it to the file fallback. The OS Keychain is NOT +// modified (regression guard for the side-effecting getMasterKey(_, true) +// call we used to make). +func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return "", keyring.ErrNotFound + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeCreatedNewKey { + t.Fatalf("result = %v, want DowngradeCreatedNewKey", result) + } + + fileKey, err := os.ReadFile(MasterKeyFilePath(service)) + if err != nil { + t.Fatalf("ReadFile(master.key.file) error = %v", err) + } + if len(fileKey) != masterKeyBytes { + t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes) + } +} + +// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard +// for the TOCTOU between the initial existence check and the final write. +// Race trace the fix closes: +// +// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes) +// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B +// then writes .enc encrypted with K_B +// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc +// +// We simulate proc B's interleaving by performing the concurrent file write +// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back +// to the final OpenFile call, the file already exists, the O_EXCL branch +// fires, and the concurrent key is preserved verbatim. +func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + service := "test-service" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + concurrentKey := make([]byte, masterKeyBytes) + for i := range concurrentKey { + concurrentKey[i] = byte(i + 77) + } + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(svc, user string) (string, error) { + if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil { + t.Fatalf("simulated concurrent write failed: %v", err) + } + return "", keyring.ErrNotFound + } + keyringSet = func(svc, user, password string) error { + t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeAlreadyDone { + t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result) + } + got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName)) + if err != nil { + t.Fatalf("ReadFile error = %v", err) + } + if !bytesEqual(got, concurrentKey) { + t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned") + } +} + +// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked" +// (the sandbox case) propagates as errKeychainBlocked through platformGet, so +// the wrapError hint chain can attach the keychain-downgrade suggestion. +func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return "", errors.New("sandbox denied keychain access") + } + keyringSet = func(service, user, password string) error { + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + account := "test-account" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + lostKey := make([]byte, masterKeyBytes) + for i := range lostKey { + lostKey[i] = byte(i + 55) + } + encrypted, err := encryptData("secret", lostKey) + if err != nil { + t.Fatalf("encryptData() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil { + t.Fatalf("WriteFile(.enc) error = %v", err) + } + + _, err = platformGet(service, account) + if !errors.Is(err, errKeychainBlocked) { + t.Fatalf("err = %v, want errKeychainBlocked", err) + } +} + +// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression +// guard for the bug where `lark-cli api ...` inside a sandbox surfaced +// "keychain access blocked" but the hint did NOT mention keychain-downgrade +// — the very command meant to recover from that exact situation. Root cause: +// the blocked path used an anonymous errors.New string, so the extraHint +// `errors.Is` check (only matched errNotInitialized) couldn't recognize it. +// +// Asserts the full wrapError → ExitError.Detail.Hint pipeline: +// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade +// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention +// - generic errors → no mention +// +// Add new cases here whenever extraHint's matcher widens, to keep the +// promise that the hint is suggested iff downgrade can actually help. +func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) { + cases := []struct { + name string + err error + wantHint bool + }{ + {"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true}, + {"not initialized (missing master key)", errNotInitialized, true}, + {"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false}, + {"unrelated generic error", errors.New("something else entirely"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := wrapError("Get", tc.err) + var ee *output.ExitError + if !errors.As(err, &ee) || ee.Detail == nil { + t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err) + } + got := strings.Contains(ee.Detail.Hint, "keychain-downgrade") + if got != tc.wantHint { + t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint) + } + }) + } +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based // master key path once the fallback master key already exists. func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) { diff --git a/internal/keychain/keychain_hint_other.go b/internal/keychain/keychain_hint_other.go new file mode 100644 index 000000000..7e3d4fe3e --- /dev/null +++ b/internal/keychain/keychain_hint_other.go @@ -0,0 +1,10 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !darwin + +package keychain + +// extraHint is a no-op on non-darwin platforms. The keychain-downgrade +// command is macOS-only, so there is no extra suggestion to surface. +func extraHint(err error) string { return "" } From 049ddf771b435e86a4f5a71e616336ec44341160 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Tue, 26 May 2026 16:56:40 +0800 Subject: [PATCH 03/62] docs(task): require --complete=false for pending standup summaries (#1101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standup workflow and the +get-my-tasks reference both implied a "pending todo summary" use case but did not pass --complete=false in the example commands. As a result, completed tasks were surfaced into standup/daily summaries as if they were still pending. This change updates the workflow and reference docs only — the underlying command behavior is unchanged. Closes #993 --- .../references/lark-task-get-my-tasks.md | 10 +++++++-- skills/lark-workflow-standup-report/SKILL.md | 22 ++++++++++--------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/skills/lark-task/references/lark-task-get-my-tasks.md b/skills/lark-task/references/lark-task-get-my-tasks.md index 101e1714e..2041cac8e 100644 --- a/skills/lark-task/references/lark-task-get-my-tasks.md +++ b/skills/lark-task/references/lark-task-get-my-tasks.md @@ -13,18 +13,24 @@ If the user query only specifies a task name (e.g., "Complete task Lobster No. 1 List tasks assigned to the current user, with support for filtering by completion status, creation time, and due date. By default, the command will automatically paginate up to 20 times. Use `--page-all` to fetch more (up to 40 pages). +> **Pending vs all tasks:** When `--complete` is not provided, the result contains **both completed and incomplete tasks**. +> For standup / daily-summary / pending-todo scenarios, you **must** pass `--complete=false`; otherwise completed tasks will be surfaced as if they were still pending. + ## Recommended Commands ```bash # Search for a specific task by name lark-cli task +get-my-tasks --query "Lobster No. 1" -# Get all my tasks (fetches up to 20 pages by default) +# Get all my tasks, both completed and incomplete (fetches up to 20 pages by default) lark-cli task +get-my-tasks -# Get my incomplete tasks (fetches up to 20 pages by default) +# Pending-only: my incomplete tasks (use this for standup/daily-summary) lark-cli task +get-my-tasks --complete=false +# Pending-only with a due-date upper bound (e.g. end of today / this week) +lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00" + # Fetch all my tasks (up to 40 pages) lark-cli task +get-my-tasks --page-all diff --git a/skills/lark-workflow-standup-report/SKILL.md b/skills/lark-workflow-standup-report/SKILL.md index c7cbf40b9..4903553ca 100644 --- a/skills/lark-workflow-standup-report/SKILL.md +++ b/skills/lark-workflow-standup-report/SKILL.md @@ -30,8 +30,8 @@ lark-cli auth login --domain calendar,task ## 工作流 ``` -{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件) - └─► task +get-my-tasks [--due-end] ──► 未完成待办列表 +{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件) + └─► task +get-my-tasks --complete=false [--due-end] ──► 未完成待办列表 │ ▼ AI 汇总(时间转换 + 冲突检测 + 排序)──► 摘要 @@ -54,19 +54,21 @@ lark-cli calendar +agenda --start "2026-03-26T00:00:00+08:00" --end "2026-03-26T ### Step 2: 获取未完成待办 ```bash -# 默认:返回分配给当前用户的未完成任务(最多 20 条) -lark-cli task +get-my-tasks +# 默认 pending 摘要:必须显式过滤未完成任务(最多 20 条) +lark-cli task +get-my-tasks --complete=false -# 只看指定日期前到期的(推荐用于摘要场景,减少数据量) -lark-cli task +get-my-tasks --due-end "2026-03-27T23:59:59+08:00" +# 只看指定日期前到期的未完成任务(推荐用于摘要场景,减少数据量) +lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00" -# 获取全部(超过 20 条时) -lark-cli task +get-my-tasks --page-all +# 获取全部未完成任务(超过 20 条时) +lark-cli task +get-my-tasks --complete=false --page-all ``` -> **注意**:不带过滤条件时可能返回大量历史待办(实测 30+ 条、100KB+),容易超出上下文限制。摘要场景建议: +> **注意**:`+get-my-tasks` 不带 `--complete` 时会**同时返回已完成和未完成任务**,会把已完成任务当成"待办"展示进摘要里。站会/日报这种 pending 汇总场景**必须**显式带上 `--complete=false`,不要省略。 +> +> 数据量层面也建议加过滤: > - 用 `--due-end` 过滤出目标日期前到期的任务 -> - 如果也需要无截止日期的任务,可不加过滤,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办" +> - 如果也需要无截止日期的任务,可不加 `--due-end`,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办" ### Step 3: AI 汇总 From b9e5b50251a57fa2b30c78c01eb9f9b2532c65e3 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Tue, 26 May 2026 17:41:26 +0800 Subject: [PATCH 04/62] docs(skills): fix agent routing for doubao.com URLs (#1082) --- skills/lark-doc/SKILL.md | 2 +- skills/lark-drive/SKILL.md | 2 +- skills/lark-sheets/SKILL.md | 2 +- skills/lark-slides/SKILL.md | 2 +- skills/lark-wiki/SKILL.md | 3 +-- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index d4155fe4f..d017de483 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-doc -description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。" +description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 0ab6a4637..cd83c9fa5 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-drive version: 1.0.0 -description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。" +description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。当用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是资源类型、URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index d620a9bbf..eba8e2b30 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-sheets version: 1.2.0 -description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。" +description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 3adc795ec..9767c6583 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-slides version: 1.0.0 -description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。" +description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index b089b47c9..3da893306 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-wiki version: 1.0.0 -description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。" +description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] @@ -116,4 +116,3 @@ lark-cli wiki [flags] # 调用 API | `nodes.move` | `wiki:node:move` | | `nodes.create` | `wiki:node:create` | | `nodes.list` | `wiki:node:retrieve` | - From cf40945bbc4671ec89562442c2fefcfcf13591ab Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Tue, 26 May 2026 18:41:50 +0800 Subject: [PATCH 05/62] feat(minutes): add minutes edit shortcuts (#1036) --- shortcuts/minutes/minutes_speaker_replace.go | 139 ++++++++++ .../minutes/minutes_speaker_replace_test.go | 247 ++++++++++++++++++ shortcuts/minutes/minutes_update.go | 94 +++++++ shortcuts/minutes/minutes_update_test.go | 154 +++++++++++ shortcuts/minutes/shortcuts.go | 2 + .../references/lark-calendar-create.md | 17 +- skills/lark-minutes/SKILL.md | 10 +- .../lark-minutes-speaker-replace.md | 50 ++++ .../references/lark-minutes-update.md | 41 +++ .../minutes/minutes_speaker_replace_test.go | 40 +++ tests/cli_e2e/minutes/minutes_update_test.go | 38 +++ 11 files changed, 821 insertions(+), 11 deletions(-) create mode 100644 shortcuts/minutes/minutes_speaker_replace.go create mode 100644 shortcuts/minutes/minutes_speaker_replace_test.go create mode 100644 shortcuts/minutes/minutes_update.go create mode 100644 shortcuts/minutes/minutes_update_test.go create mode 100644 skills/lark-minutes/references/lark-minutes-speaker-replace.md create mode 100644 skills/lark-minutes/references/lark-minutes-update.md create mode 100644 tests/cli_e2e/minutes/minutes_speaker_replace_test.go create mode 100644 tests/cli_e2e/minutes/minutes_update_test.go diff --git a/shortcuts/minutes/minutes_speaker_replace.go b/shortcuts/minutes/minutes_speaker_replace.go new file mode 100644 index 000000000..ccba0ea8e --- /dev/null +++ b/shortcuts/minutes/minutes_speaker_replace.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + minutesSpeakerReplaceSpeakerNotFoundCode = 2091001 + minutesSpeakerReplaceNoEditPermission = 2091005 +) + +// MinutesSpeakerReplace replaces a speaker in a minute's transcript. +var MinutesSpeakerReplace = common.Shortcut{ + Service: "minutes", + Command: "+speaker-replace", + Description: "Replace a speaker in a minute's transcript (rebind from one user to another)", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true}, + {Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + if minuteToken == "" { + return output.ErrValidation("--minute-token is required") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return output.ErrValidation("%s", err) + } + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + if fromUserID == "" { + return output.ErrValidation("--from-user-id is required") + } + if _, err := common.ValidateUserID(fromUserID); err != nil { + return output.ErrValidation("--from-user-id: %s", err) + } + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + if toUserID == "" { + return output.ErrValidation("--to-user-id is required") + } + if _, err := common.ValidateUserID(toUserID); err != nil { + return output.ErrValidation("--to-user-id: %s", err) + } + if fromUserID == toUserID { + return output.ErrValidation("--from-user-id and --to-user-id must be different") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + return common.NewDryRunAPI(). + PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))). + Body(map[string]interface{}{ + "minute_token": minuteToken, + "from_user_id": fromUserID, + "to_user_id": toUserID, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + + body := map[string]interface{}{ + "minute_token": minuteToken, + "from_user_id": fromUserID, + "to_user_id": toUserID, + } + + _, err := runtime.CallAPI(http.MethodPut, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), + nil, body) + if err != nil { + return minutesSpeakerReplaceError(err, minuteToken, fromUserID) + } + + outData := map[string]interface{}{ + "minute_token": minuteToken, + "from_user_id": fromUserID, + "to_user_id": toUserID, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} + +func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + return err + } + + switch exitErr.Detail.Code { + case minutesSpeakerReplaceNoEditPermission: + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "no_edit_permission", + Code: minutesSpeakerReplaceNoEditPermission, + Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken), + Hint: "Ask the minute owner for minute edit permission", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } + case minutesSpeakerReplaceSpeakerNotFoundCode: + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "speaker_not_found", + Code: minutesSpeakerReplaceSpeakerNotFoundCode, + Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID), + Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } + } + + return err +} diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go new file mode 100644 index 000000000..899f15acd --- /dev/null +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +const minutesSpeakerReplaceTestToken = "obcnexampleminute" + +func TestMinutesSpeakerReplace_Validate(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing minute token", + args: []string{"+speaker-replace", "--from-user-id", "ou_a", "--to-user-id", "ou_b", "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing from", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"}, + wantErr: "required flag(s) \"from-user-id\" not set", + }, + { + name: "missing to", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--as", "user"}, + wantErr: "required flag(s) \"to-user-id\" not set", + }, + { + name: "invalid from prefix", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, + wantErr: "--from-user-id", + }, + { + name: "invalid to prefix", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"}, + wantErr: "--to-user-id", + }, + { + name: "from equals to", + args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, + wantErr: "must be different", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesSpeakerReplace.Mount(parent, f) + parent.SetArgs(tt.args) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error()) + } + }) + } +} + +func TestMinutesSpeakerReplace_DryRun(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-user-id", "ou_old_speaker", + "--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, "PUT") { + t.Errorf("expected PUT method, got:\n%s", out) + } + if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSpeakerReplaceTestToken+"/transcript/speaker") { + t.Errorf("expected speaker endpoint, got:\n%s", out) + } + if !strings.Contains(out, "ou_old_speaker") { + t.Errorf("expected from_user_id in body, 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(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.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope struct { + Data struct { + MinuteToken string `json:"minute_token"` + FromUserID string `json:"from_user_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.MinuteToken != minutesSpeakerReplaceTestToken { + t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesSpeakerReplaceTestToken) + } + if envelope.Data.FromUserID != "ou_old_speaker" { + t.Errorf("data.from_user_id = %q, want ou_old_speaker", envelope.Data.FromUserID) + } + if envelope.Data.ToUserID != "ou_new_speaker" { + t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID) + } +} + +func TestMinutesSpeakerReplace_SpeakerNotFound(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.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 2091001, + "msg": "speaker not exist", + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_missing_speaker", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected speaker-not-found error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil { + t.Fatalf("expected structured error detail, got nil") + } + if exitErr.Detail.Type != "speaker_not_found" { + t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "Speaker not found") { + t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") { + t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") { + t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint) + } +} + +func TestMinutesSpeakerReplace_NoEditPermission(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.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 2091005, + "msg": "no edit permission", + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil { + t.Fatalf("expected structured error detail, got nil") + } + if exitErr.Detail.Type != "no_edit_permission" { + t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "No edit permission") { + t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) { + t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "edit permission") { + t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint) + } +} diff --git a/shortcuts/minutes/minutes_update.go b/shortcuts/minutes/minutes_update.go new file mode 100644 index 000000000..bdf30c680 --- /dev/null +++ b/shortcuts/minutes/minutes_update.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const minutesUpdateNoEditPermissionCode = 2091005 + +// MinutesUpdate updates the title (topic) of a minute. +var MinutesUpdate = common.Shortcut{ + Service: "minutes", + Command: "+update", + Description: "Update a minute's title", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "topic", Desc: "new minute title", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + if minuteToken == "" { + return output.ErrValidation("--minute-token is required") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return output.ErrValidation("%s", err) + } + if strings.TrimSpace(runtime.Str("topic")) == "" { + return output.ErrValidation("--topic is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + return common.NewDryRunAPI(). + PATCH(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken))). + Body(map[string]interface{}{"topic": runtime.Str("topic")}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + topic := runtime.Str("topic") + + body := map[string]interface{}{ + "topic": topic, + } + + _, err := runtime.CallAPI(http.MethodPatch, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), + nil, body) + if err != nil { + return minutesUpdateError(err, minuteToken) + } + + outData := map[string]interface{}{ + "minute_token": minuteToken, + "topic": topic, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} + +func minutesUpdateError(err error, minuteToken string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode { + return err + } + + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "no_edit_permission", + Code: minutesUpdateNoEditPermissionCode, + Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken), + Hint: "Ask the minute owner for minute edit permission", + Detail: exitErr.Detail.Detail, + }, + Err: err, + } +} diff --git a/shortcuts/minutes/minutes_update_test.go b/shortcuts/minutes/minutes_update_test.go new file mode 100644 index 000000000..c061eccf5 --- /dev/null +++ b/shortcuts/minutes/minutes_update_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +const minutesUpdateTestToken = "obcnexampleminute" + +func TestMinutesUpdate_Validate(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing minute token", + args: []string{"+update", "--topic", "new title", "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing topic", + args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"}, + wantErr: "required flag(s) \"topic\" not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesUpdate.Mount(parent, f) + parent.SetArgs(tt.args) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error()) + } + }) + } +} + +func TestMinutesUpdate_DryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesUpdate, []string{ + "+update", + "--minute-token", minutesUpdateTestToken, + "--topic", "周会纪要", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "PATCH") { + t.Errorf("expected PATCH method, got:\n%s", out) + } + if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesUpdateTestToken) { + t.Errorf("expected PATCH /open-apis/minutes/v1/minutes/, got:\n%s", out) + } + if !strings.Contains(out, "周会纪要") { + t.Errorf("expected topic in body, got:\n%s", out) + } +} + +func TestMinutesUpdate_Execute(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.MethodPatch, + URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesUpdate, []string{ + "+update", + "--minute-token", minutesUpdateTestToken, + "--topic", "新标题", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMinutesUpdate_NoEditPermission(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.MethodPatch, + URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken, + Body: map[string]interface{}{ + "code": 2091005, + "msg": "no edit permission", + }, + }) + + err := mountAndRun(t, MinutesUpdate, []string{ + "+update", + "--minute-token", minutesUpdateTestToken, + "--topic", "新标题", + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Detail == nil { + t.Fatalf("expected structured error detail, got nil") + } + if exitErr.Detail.Type != "no_edit_permission" { + t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, "No edit permission") { + t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) { + t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "edit permission") { + t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 8aef2b058..75d0a70b9 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -11,5 +11,7 @@ func Shortcuts() []common.Shortcut { MinutesSearch, MinutesDownload, MinutesUpload, + MinutesUpdate, + MinutesSpeakerReplace, } } diff --git a/skills/lark-calendar/references/lark-calendar-create.md b/skills/lark-calendar/references/lark-calendar-create.md index e0aab9c9c..7d0b090e3 100644 --- a/skills/lark-calendar/references/lark-calendar-create.md +++ b/skills/lark-calendar/references/lark-calendar-create.md @@ -59,14 +59,12 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \ ## 查看完整参数定义 lark-cli schema calendar.events.create ## 创建日程 -lark-cli calendar events create --calendar-id primary --data '{ - "summary": "产品评审", - "description": "本周分享主题:CLI 架构设计", +lark-cli calendar events create \ + --params '{"calendar_id":""}' \ + --data '{ + "summary": "技术分享:CLI 架构设计", "start_time": { "timestamp": "1741586400" }, - "end_time": { "timestamp": "1741593600" }, - "location": { "name": "5F-大会议室" }, - "attendee_ability": "can_modify_event", - "reminders": [{ "minutes": 15 }] + "end_time": { "timestamp": "1741593600" } }' # 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id) @@ -74,7 +72,7 @@ lark-cli calendar events create --calendar-id primary --data '{ lark-cli schema calendar.event.attendees.create ## 添加参会人 lark-cli calendar event.attendees create \ - --calendar-id --event-id \ + --params '{"calendar_id":"","event_id":""}' \ --data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}' # 可选第三步(推荐):若第二步失败,回滚删除空日程 @@ -82,8 +80,7 @@ lark-cli calendar event.attendees create \ lark-cli schema calendar.events.delete ## 删除空日程 lark-cli calendar events delete \ - --calendar-id --event-id \ - --params '{"need_notification":false}' + --params '{"calendar_id":"","event_id":"","need_notification":false}' ``` diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index 749816e58..c10cbe47b 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg`、`whisper` 等本地转写命令。飞书妙记 URL 格式: http(s):///minutes/" +description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物;6.更新妙记标题(重命名妙记);7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s):///minutes/" metadata: requires: bins: ["lark-cli"] @@ -98,6 +98,8 @@ Minutes (妙记) ← minute_token 标识 > - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) > - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` > - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens` +> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` +> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` ## Shortcuts(推荐优先使用) @@ -108,10 +110,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]` | [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range | | [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | | [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute | +| [`+update`](references/lark-minutes-update.md) | Update a minute's title | +| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) | - 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。 - 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。 - 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。 +- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。 +- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID,不支持姓名)。 @@ -135,5 +141,7 @@ lark-cli minutes [flags] # 调用 API | `+search` | `minutes:minutes.search:read` | | `minutes.get` | `minutes:minutes:readonly` | | `+download` | `minutes:minutes.media:export` | +| `+update` | `minutes:minutes:update` | +| `+speaker-replace` | `minutes:minutes:update` | diff --git a/skills/lark-minutes/references/lark-minutes-speaker-replace.md b/skills/lark-minutes/references/lark-minutes-speaker-replace.md new file mode 100644 index 000000000..12b82c63f --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-speaker-replace.md @@ -0,0 +1,50 @@ +# minutes +speaker-replace + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。 + +本 skill 对应 shortcut:`lark-cli minutes +speaker-replace`。 + +## 典型触发表达 + +- "把这条妙记里 A 的发言改成 B" +- "妙记说话人识别错了,帮我把张三的部分换成李四" +- "妙记说话人修改 / 替换 / 重新归属" +- "改一下妙记的说话人" + +## 命令示例 + +```bash +lark-cli minutes +speaker-replace \ + --minute-token obcnxxxxxxxxxxxxxxxxxxxx \ + --from-user-id ou_old_speaker_open_id \ + --to-user-id ou_new_speaker_open_id +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 | +| `--from-user-id ` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | +| `--to-user-id ` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | + +> **重要**:`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID,**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。 + +## 认证与权限 + +- 所需 scope:`minutes:minutes:update`。 + +## 输出结果 + +| 字段 | 说明 | +|------|------| +| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致 | +| `from_user_id` | 被替换的原说话人 open_id,与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 | +| `to_user_id` | 替换后的新说话人 open_id,与输入的 `--to-user-id` 一致 | + +## 参考 + +- [lark-minutes](../SKILL.md) -- 妙记相关功能说明 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-minutes/references/lark-minutes-update.md b/skills/lark-minutes/references/lark-minutes-update.md new file mode 100644 index 000000000..780066093 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-update.md @@ -0,0 +1,41 @@ +# minutes +update + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +修改飞书妙记的标题(topic)。 + +本 skill 对应 shortcut:`lark-cli minutes +update`。 + +## 典型触发表达 + +- "把这个妙记的标题改成 xxx" +- "重命名这条妙记" +- "修改妙记标题" + +## 命令示例 + +```bash +lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 | +| `--topic ` | 是 | 新的妙记标题 | + +## 认证与权限 +- 所需 scope:`minutes:minutes:update`。 + +## 输出结果 + +| 字段 | 说明 | +|------|------| +| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致,可继续用于查询妙记信息、下载媒体或获取纪要产物 | +| `topic` | 修改后的妙记标题,与输入的 `--topic` 一致 | + +## 参考 + +- [lark-minutes](../SKILL.md) -- 妙记相关功能说明 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go new file mode 100644 index 000000000..70cf3cceb --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMinutesSpeakerReplace_DryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+speaker-replace", + "--minute-token", "obcnexampleminute", + "--from-user-id", "ou_old_speaker", + "--to-user-id", "ou_new_speaker", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "ou_old_speaker"), "dry-run should contain from_user_id, got: %s", output) + assert.True(t, strings.Contains(output, "ou_new_speaker"), "dry-run should contain to_user_id, got: %s", output) +} diff --git a/tests/cli_e2e/minutes/minutes_update_test.go b/tests/cli_e2e/minutes/minutes_update_test.go new file mode 100644 index 000000000..b452ff599 --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_update_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMinutesUpdate_DryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+update", + "--minute-token", "obcnexampleminute", + "--topic", "新的妙记标题", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PATCH"), "dry-run should contain PATCH method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "新的妙记标题"), "dry-run should contain topic, got: %s", output) +} From 0bf590d01ac580461135018c94fed03ada47ba3e Mon Sep 17 00:00:00 2001 From: zhangjun-bytedance Date: Tue, 26 May 2026 18:42:29 +0800 Subject: [PATCH 06/62] feat: get minutes keywords (#1079) Parse keywords from minutes artifacts API in vc +notes and document the field in lark-vc skill references. Co-authored-by: Cursor --- shortcuts/vc/vc_notes.go | 3 +++ shortcuts/vc/vc_notes_test.go | 1 + skills/lark-vc/SKILL.md | 3 ++- skills/lark-vc/references/lark-vc-notes.md | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 2c1455e27..4b03a7e7d 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -414,6 +414,9 @@ func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, re if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 { result["chapters"] = chapters } + if keywords, ok := data["keywords"].([]any); ok && len(keywords) > 0 { + result["keywords"] = keywords + } } // parseArtifactType extracts artifact_type as int from varying JSON number representations. diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index 9027cce17..5757c515e 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -126,6 +126,7 @@ func artifactsStub(token string) *httpmock.Stub { "summary": "Test summary content", "minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}}, "minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}}, + "keywords": []interface{}{"budget", "roadmap"}, }, }, } diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index 2623de1aa..e471b16bc 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -96,7 +96,8 @@ Meeting (视频会议) ├── Transcript (文字记录) ├── Summary (总结) ├── Todos (待办) - └── Chapters (章节) + ├── Chapters (章节) + └── Keywords (推荐关键词) ``` > **注意**:`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。 diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index 1ada83534..00b5fd7e1 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -93,6 +93,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | `artifacts.summary` | AI 总结(JSON 内联) | | `artifacts.todos` | 待办事项(JSON 内联) | | `artifacts.chapters` | 章节纪要(JSON 内联) | +| `artifacts.keywords` | 妙记推荐关键词(JSON 内联) | | `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` | ## 如何获取输入参数 From 137176e8b06cb3c88a38e45847b0f5f56a40b495 Mon Sep 17 00:00:00 2001 From: zhangheng023 Date: Tue, 26 May 2026 19:23:08 +0800 Subject: [PATCH 07/62] fix: sync skills incrementally during update (#1042) --- cmd/root_integration_test.go | 17 +- cmd/update/update.go | 138 ++++---- cmd/update/update_test.go | 510 ++++++++++++++++++++------- internal/selfupdate/updater.go | 53 ++- internal/selfupdate/updater_test.go | 85 +++++ internal/skillscheck/check.go | 37 +- internal/skillscheck/check_test.go | 33 +- internal/skillscheck/notice.go | 8 +- internal/skillscheck/stamp.go | 49 --- internal/skillscheck/stamp_test.go | 113 ------ internal/skillscheck/state.go | 92 +++++ internal/skillscheck/state_test.go | 139 ++++++++ internal/skillscheck/sync.go | 399 +++++++++++++++++++++ internal/skillscheck/sync_test.go | 517 ++++++++++++++++++++++++++++ 14 files changed, 1766 insertions(+), 424 deletions(-) delete mode 100644 internal/skillscheck/stamp.go delete mode 100644 internal/skillscheck/stamp_test.go create mode 100644 internal/skillscheck/state.go create mode 100644 internal/skillscheck/state_test.go create mode 100644 internal/skillscheck/sync.go create mode 100644 internal/skillscheck/sync_test.go diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index d19fec034..b7bdb1bbc 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -384,11 +384,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { }) } -// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp -// produces no skills key in the composed notice. Users who installed -// skills via `npx skills add` (no stamp) must not see the misleading -// "not installed" notice — only `lark-cli update` users opt into the -// drift tracker. +// TestSetupNotices_ColdStart_NoNotice verifies that missing state +// produces no skills key in the composed notice. func TestSetupNotices_ColdStart_NoNotice(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() @@ -419,13 +416,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) { } } -// TestSetupNotices_InSync verifies that a matching stamp produces no +// TestSetupNotices_InSync verifies that matching state produces no // skills key in the composed notice. func TestSetupNotices_InSync(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.21"); err != nil { + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } @@ -452,13 +449,13 @@ func TestSetupNotices_InSync(t *testing.T) { } } -// TestSetupNotices_Drift verifies a mismatching stamp produces the +// TestSetupNotices_Drift verifies mismatching state produces the // drift message with both current and target populated. func TestSetupNotices_Drift(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } @@ -507,7 +504,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } diff --git a/cmd/update/update.go b/cmd/update/update.go index c9035cd5c..6b8ce5091 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -31,15 +31,18 @@ var ( currentVersion = func() string { return build.Version } currentOS = runtime.GOOS newUpdater = func() *selfupdate.Updater { return selfupdate.New() } + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) } ) func isWindows() bool { return currentOS == osWindows } -// normalizeVersion canonicalizes a version string for stamp comparison. +// normalizeVersion canonicalizes a version string for state comparison. // Strips a leading "v" so versions written from Makefile (git describe → // "v1.0.0") and npm (no prefix → "1.0.0") compare equal. func normalizeVersion(s string) string { - return strings.TrimPrefix(strings.TrimSpace(s), "v") + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + return strings.TrimPrefix(s, "V") } func releaseURL(version string) string { @@ -121,7 +124,9 @@ func updateRun(opts *UpdateOptions) error { cur := currentVersion() updater := newUpdater() - updater.CleanupStaleFiles() + if !opts.Check { + updater.CleanupStaleFiles() + } output.PendingNotice = nil // 1. Fetch latest version @@ -137,13 +142,9 @@ func updateRun(opts *UpdateOptions) error { // 3. Compare versions if !opts.Force && !update.IsNewer(latest, cur) { - // Run skills sync before returning — covers the case where the - // binary is already current but skills were never synced. - // Stamp dedup makes this a no-op if skills are already in sync. - // Skip side-effects under --check (pure report path per spec §3.6). - var skillsResult *selfupdate.NpmResult + var skillsResult *skillscheck.SyncResult if !opts.Check { - skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force) + skillsResult = runSkillsAndState(updater, io, cur, opts.Force) } return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check) } @@ -185,16 +186,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s "message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest), "url": releaseURL(latest), "changelog": changelogURL(), } - // skills_status: pure report, no side effect, no stamp write. - // ReadStamp errors are silently swallowed — if we can't read the - // stamp we just omit the block rather than fail the --check. - if stamp, err := skillscheck.ReadStamp(); err == nil { - out["skills_status"] = map[string]interface{}{ - "current": stamp, - "target": cur, - "in_sync": stamp == cur, - } - } + applySkillsStatus(out, cur) output.PrintJson(io.Out, out) return nil } @@ -210,7 +202,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s } func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { - skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force) + skillsResult := runSkillsAndState(updater, io, cur, opts.Force) reason := detect.ManualReason() if opts.JSON { @@ -288,10 +280,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, return output.ErrBare(output.ExitAPI) } - // Skills update (best-effort) — uses runSkillsAndStamp so the - // stamp gets persisted on success and dedup applies if a previous - // run already stamped this version. - skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force) + skillsResult := runSkillsAndState(updater, io, latest, opts.Force) if opts.JSON { result := map[string]interface{}{ @@ -328,27 +317,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) } -// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the -// stamp on success. Skips the npx invocation when the stamp already -// matches stampVersion (unless force is true). The stamp write failure -// emits a warning to io.ErrOut but does NOT fail the update command — -// best-effort. ReadStamp errors are swallowed (fail-closed: treated as -// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp -// dedup; otherwise returns the underlying *NpmResult with Err semantics -// from RunSkillsUpdate. -func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult { +func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult { if !force { - if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) { + if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) { return nil } } - r := updater.RunSkillsUpdate() - if r.Err == nil { - if err := skillscheck.WriteStamp(stampVersion); err != nil { - fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err) - } + result := syncSkills(skillscheck.SyncOptions{ + Version: stateVersion, + Force: force, + Runner: updater, + }) + if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") { + fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err) } - return r + return result } // reportAlreadyUpToDate emits the JSON / pretty output for the @@ -356,7 +339,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp // fields derived from skillsResult. When check is true, this is the pure // report path (spec §3.6): no side-effects, JSON envelope uses // skills_status (spec §4.2) instead of skills_action. -func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error { +func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error { if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, @@ -364,16 +347,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late "message": fmt.Sprintf("lark-cli %s is already up to date", cur), } if check { - // Pure report — read stamp directly, emit skills_status block. - // ReadStamp errors are silently swallowed — if we can't read - // the stamp we just omit the block rather than fail the --check. - if stamp, err := skillscheck.ReadStamp(); err == nil { - out["skills_status"] = map[string]interface{}{ - "current": stamp, - "target": cur, - "in_sync": stamp == cur, - } - } + applySkillsStatus(out, cur) } else { applySkillsResult(out, skillsResult) } @@ -387,36 +361,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late return nil } -// applySkillsResult mutates the JSON envelope to include skills_action -// (and skills_warning when failed). nil result = "in_sync" (dedup hit). -func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) { +func applySkillsStatus(env map[string]interface{}, target string) { + state, readable, err := skillscheck.ReadState() + if err != nil || !readable || state.Version == "" { + return + } + status := map[string]interface{}{ + "current": state.Version, + "target": target, + "in_sync": normalizeVersion(state.Version) == normalizeVersion(target), + } + if len(state.OfficialSkills) > 0 { + status["official"] = len(state.OfficialSkills) + } + if len(state.UpdatedSkills) > 0 { + status["updated"] = len(state.UpdatedSkills) + } + if len(state.SkippedDeletedSkills) > 0 { + status["skipped_deleted"] = state.SkippedDeletedSkills + } + env["skills_status"] = status +} + +func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) { switch { case r == nil: env["skills_action"] = "in_sync" case r.Err != nil: env["skills_action"] = "failed" env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err) - if detail := strings.TrimSpace(r.Stderr.String()); detail != "" { - env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput) - } + env["skills_summary"] = skillsSummary(r) default: env["skills_action"] = "synced" + env["skills_summary"] = skillsSummary(r) } } -// emitSkillsTextHints prints human-readable feedback about the skills -// sync result for non-JSON output. -func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) { +func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} { + summary := map[string]interface{}{ + "official": len(r.Official), + "updated": len(r.Updated), + "added": len(r.Added), + "skipped_deleted": len(r.SkippedDeleted), + } + if len(r.Failed) > 0 { + summary["failed"] = r.Failed + } + return summary +} + +func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) { switch { case r == nil: - // dedup hit — silent (already up to date) case r.Err != nil: fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err) - if detail := strings.TrimSpace(r.Stderr.String()); detail != "" { - fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail)) + if len(r.Failed) > 0 { + fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", ")) } - fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n") + fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n") + case r.Force: + fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official)) default: - fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK()) + fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted)) + if len(r.SkippedDeleted) > 0 { + fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n") + } } } diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 250aa83db..5cfe52477 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -5,13 +5,14 @@ package cmdupdate import ( "bytes" + "context" "encoding/json" "errors" "fmt" - "os" - "path/filepath" + "os/exec" "strings" "testing" + "time" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -28,7 +29,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe } // mockDetect sets up newUpdater to return an Updater with the given DetectResult. -// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later. func mockDetect(t *testing.T, result selfupdate.DetectResult) { t.Helper() origNew := newUpdater @@ -41,22 +41,53 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) { } // mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once. -func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, - npmFn func(string) *selfupdate.NpmResult, - skillsFn func() *selfupdate.NpmResult) { +func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) { t.Helper() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return result } u.NpmInstallOverride = npmFn - u.SkillsUpdateOverride = skillsFn u.VerifyOverride = func(string) error { return nil } + u.SkillsCommandOverride = successfulSkillsCommand() return u } t.Cleanup(func() { newUpdater = origNew }) } +func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult { + return func(args ...string) *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + switch strings.Join(args, " ") { + case "-y skills add https://open.feishu.cn --list": + r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n") + case "-y skills ls -g": + r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n") + default: + } + return r + } +} + +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "1.2.3", want: "1.2.3"}, + {input: "v1.2.3", want: "1.2.3"}, + {input: "V1.2.3", want: "1.2.3"}, + {input: " v1.2.3 ", want: "1.2.3"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := normalizeVersion(tt.input); got != tt.want { + t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestUpdateAlreadyUpToDate_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) @@ -168,9 +199,7 @@ func TestUpdateManual_Human(t *testing.T) { } func TestUpdateNpm_JSON(t *testing.T) { - // Isolate config dir: this test mocks fetchLatest="2.0.0" and lets - // runSkillsAndStamp → WriteStamp succeed, which without isolation would - // clobber the real ~/.lark-cli/skills.stamp with "2.0.0". + // Isolate config dir because skills sync writes skills-state.json. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -186,7 +215,6 @@ func TestUpdateNpm_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -216,7 +244,6 @@ func TestUpdateNpm_Human(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -230,7 +257,7 @@ func TestUpdateNpm_Human(t *testing.T) { } func TestUpdateForce_JSON(t *testing.T) { - // Same stamp-isolation rationale as TestUpdateNpm_JSON. + // Same state-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -246,7 +273,6 @@ func TestUpdateForce_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -323,7 +349,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) { } func TestUpdateDevVersion_JSON(t *testing.T) { - // Same stamp-isolation rationale as TestUpdateNpm_JSON. + // Same state-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -339,7 +365,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -451,8 +476,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing. u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return errors.New("bad binary") } u.RestoreAvailableOverride = func() bool { return false } - u.SkillsUpdateOverride = func() *selfupdate.NpmResult { - t.Fatal("skills update should not run when binary verification fails") + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { + t.Fatal("skills sync should not run when binary verification fails") return nil } return u @@ -649,7 +674,7 @@ func TestPermissionHint(t *testing.T) { func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) { // With the rename trick, Windows npm installs can now auto-update. - // Same stamp-isolation rationale as TestUpdateNpm_JSON. + // Same state-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -668,7 +693,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -750,7 +774,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -785,8 +808,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } - // Skills update fails - u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") r.Err = fmt.Errorf("exit status 127") @@ -812,8 +834,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { if !strings.Contains(out, "skills_warning") { t.Errorf("expected skills_warning in output, got: %s", out) } - if !strings.Contains(out, "skills_detail") { - t.Errorf("expected skills_detail in output, got: %s", out) + if !strings.Contains(out, "skills_summary") { + t.Errorf("expected skills_summary in output, got: %s", out) } } @@ -838,7 +860,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) { } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } - u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") r.Err = fmt.Errorf("exit status 127") @@ -861,100 +883,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) { if !strings.Contains(out, "Skills update failed") { t.Errorf("expected skills failure warning, got: %s", out) } - if !strings.Contains(out, "npx -y skills add") { - t.Errorf("expected manual skills command hint, got: %s", out) + if !strings.Contains(out, "lark-cli update --force") { + t.Errorf("expected force retry hint, got: %s", out) } } -// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable -// for direct calls to internals like runSkillsAndStamp that write to -// io.ErrOut. +// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers. func newTestIO() *cmdutil.IOStreams { return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) } -func TestRunSkillsAndStamp_DedupHit(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.21"); err != nil { +func TestRunSkillsAndState_DedupHit(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } called := false updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { called = true return &selfupdate.NpmResult{} }, } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got != nil { - t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got) + t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got) } if called { - t.Error("SkillsUpdateOverride called, want skipped due to dedup") + t.Error("SkillsCommandOverride called, want skipped due to dedup") } } -func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.21"); err != nil { +func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } called := false updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { called = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true) - if got == nil { - t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil") + got := runSkillsAndState(updater, newTestIO(), "1.0.21", true) + if got == nil || got.Err != nil { + t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got) } if !called { - t.Error("SkillsUpdateOverride not called with force=true") + t.Error("SkillsCommandOverride not called with force=true") } } -func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { - return &selfupdate.NpmResult{} - }, - } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) +func TestRunSkillsAndState_SuccessWritesState(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()} + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err != nil { - t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got) + t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got) } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.21" { - t.Errorf("stamp = %q, want \"1.0.21\"", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.21" { + t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version) } } -func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { +func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Err = fmt.Errorf("npx failed") return r }, } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err == nil { - t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got) + t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got) } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.20" { - t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version) } } @@ -973,8 +991,7 @@ func TestTruncate(t *testing.T) { } func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) origFetch := fetchLatest origCur := currentVersion @@ -987,9 +1004,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1000,17 +1017,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { - t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called") + t.Error("skills sync not called in already-up-to-date branch") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.21" { - t.Errorf("stamp = %q, want \"1.0.21\"", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.21" { + t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version) } } func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) origFetch := fetchLatest origCur := currentVersion @@ -1029,9 +1048,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { ResolvedPath: "/usr/local/bin/lark-cli", } }, - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1042,17 +1061,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { - t.Error("RunSkillsUpdate not called in manual branch, want called") + t.Error("skills sync not called in manual branch") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.21" { - t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.21" { + t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version) } } -func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) +func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) origFetch := fetchLatest origCur := currentVersion @@ -1075,9 +1096,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { return &selfupdate.NpmResult{} }, VerifyOverride: func(expectedVersion string) error { return nil }, - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1088,18 +1109,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { - t.Error("RunSkillsUpdate not called in npm branch") + t.Error("skills sync not called in npm branch") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.22" { - t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.22" { + t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version) } } func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{ + Version: "1.0.20", + OfficialSkills: []string{"lark-calendar", "lark-mail"}, + UpdatedSkills: []string{"lark-calendar"}, + SkippedDeletedSkills: []string{"lark-mail"}, + }); err != nil { t.Fatal(err) } @@ -1117,9 +1145,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true} }, - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1130,7 +1158,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { t.Fatalf("updateRun(--check) err = %v, want nil", err) } if skillsCalled { - t.Error("RunSkillsUpdate called under --check, want skipped (pure report)") + t.Error("skills sync called under --check, want skipped") } var env map[string]interface{} @@ -1144,12 +1172,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false { t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status) } + if status["official"] != float64(2) || status["updated"] != float64(1) { + t.Errorf("skills_status counts = %+v, want official:2 updated:1", status) + } } func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } @@ -1164,9 +1194,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1177,12 +1207,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err) } if skillsCalled { - t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)") + t.Error("skills sync called under --check (already-latest), want skipped") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.20" { - t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version) } var env map[string]interface{} @@ -1204,39 +1237,248 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { } } -// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning -// emission when RunSkillsUpdate succeeds but WriteStamp fails. -func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) { - // Force WriteStamp to fail by pointing config dir at a path that exists - // as a regular file (so MkdirAll fails). - tmp := t.TempDir() - badPath := filepath.Join(tmp, "blocker") - if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil { - t.Fatal(err) +func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) { + origSync := syncSkills + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { + return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")} } - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath) + t.Cleanup(func() { syncSkills = origSync }) f, _, stderr := newTestFactory(t) - updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { - return &selfupdate.NpmResult{} // success - }, + got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false) + if got == nil || got.Err == nil { + t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got) } - got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false) - if got == nil || got.Err != nil { - t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got) - } - if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") { + if !strings.Contains(stderr.String(), "warning: skills synced but state not written") { t.Errorf("stderr does not contain warning: %q", stderr.String()) } } -// TestEmitSkillsTextHints_Success verifies the "Skills updated" success -// message is printed to ErrOut on a successful (Err == nil) result. func TestEmitSkillsTextHints_Success(t *testing.T) { f, _, stderr := newTestFactory(t) - emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success + emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}}) if !strings.Contains(stderr.String(), "Skills updated") { t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String()) } } + +// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that +// verifies "lark-cli update" correctly triggers skills sync and rewrites the +// state file. It calls the real npx skills CLI, so the test is skipped when +// npx or the skills registry is unavailable (e.g. no network or fork PRs). +func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) { + // Phase 1: Verify the real npx skills CLI is available; skip otherwise. + if _, err := exec.LookPath("npx"); err != nil { + t.Skipf("npx not found in PATH: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil { + t.Skipf("real skills CLI unavailable: %v", err) + } + globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output() + if err != nil { + t.Skipf("real global skills CLI unavailable: %v", err) + } + localSkills := skillscheck.ParseSkillsList(string(globalOut)) + if err := ctx.Err(); err != nil { + t.Skipf("real skills CLI availability check timed out: %v", err) + } + + // Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19. + // lark-doc and lark-mail are recorded as skipped/deleted, meaning the user + // intentionally removed them while they were still official skills. + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + before := skillscheck.SkillsState{ + Version: "1.0.19", + OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"}, + UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"}, + AddedOfficialSkills: []string{}, + SkippedDeletedSkills: []string{}, + UpdatedAt: "2026-05-20T00:00:00Z", + } + if err := skillscheck.WriteState(before); err != nil { + t.Fatal(err) + } + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.19" { + t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version) + } + + // Phase 3: Mock version functions so the update command believes it has + // upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json". + // This triggers SyncSkills which calls the real npx skills add command. + origFetch := fetchLatest + origVersion := currentVersion + t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion }) + fetchLatest = func() (string, error) { return "1.0.20", nil } + currentVersion = func() string { return "1.0.20" } + + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("lark-cli update --json err = %v, want nil", err) + } + + // Phase 4: Verify the state file was rewritten with the new version, + // non-empty official/updated skill lists, and a refreshed timestamp. + state, readable, err = skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version after update = %q, want 1.0.20", state.Version) + } + if len(state.OfficialSkills) == 0 { + t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state) + } + if len(state.UpdatedSkills) == 0 { + t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state) + } + if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt { + t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt) + } + // Verify that previously-skipped skills are handled correctly: + // - If locally installed → should appear in UpdatedSkills (updated to latest) + // - If locally absent → should NOT be force-restored in UpdatedSkills, + // and should remain in SkippedDeletedSkills + for _, skill := range []string{"lark-doc", "lark-mail"} { + if containsString(localSkills, skill) { + if !containsString(state.UpdatedSkills, skill) { + t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill) + } + continue + } + if containsString(state.UpdatedSkills, skill) { + t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill) + } + if !containsString(state.SkippedDeletedSkills, skill) { + t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill) + } + } + + // Phase 5: Verify the JSON output structure is parseable and contains + // the expected action fields for AI agent consumption. + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) + } + if env["action"] != "already_up_to_date" { + t.Errorf("action = %v, want already_up_to_date", env["action"]) + } + if env["skills_action"] != "synced" { + t.Errorf("skills_action = %v, want synced", env["skills_action"]) + } +} + +// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does +// not exist (cold start), the update command installs all official skills and +// writes a fresh state file. No skill should appear in SkippedDeletedSkills +// because there is no previous state to preserve user deletions from. +// This is a live integration test that calls the real npx skills CLI; it is +// skipped when npx or the skills registry is unavailable. +func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) { + // Phase 1: Verify the real npx skills CLI is available; skip otherwise. + if _, err := exec.LookPath("npx"); err != nil { + t.Skipf("npx not found in PATH: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil { + t.Skipf("real skills CLI unavailable: %v", err) + } + globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output() + if err != nil { + t.Skipf("real global skills CLI unavailable: %v", err) + } + localSkills := skillscheck.ParseSkillsList(string(globalOut)) + if err := ctx.Err(); err != nil { + t.Skipf("real skills CLI availability check timed out: %v", err) + } + + // Phase 2: Use an isolated config dir with no pre-existing skills-state.json. + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if _, readable, _ := skillscheck.ReadState(); readable { + t.Fatal("skills-state.json should not exist before update") + } + + // Phase 3: Mock version functions so the update command believes it is at + // v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills + // which calls the real npx skills add command. + origFetch := fetchLatest + origVersion := currentVersion + t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion }) + fetchLatest = func() (string, error) { return "1.0.20", nil } + currentVersion = func() string { return "1.0.20" } + + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("lark-cli update --json err = %v, want nil", err) + } + + // Phase 4: Verify the state file was created with all official skills in + // UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior + // deletions to honor). Locally installed skills should appear in UpdatedSkills. + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version = %q, want 1.0.20", state.Version) + } + if len(state.OfficialSkills) == 0 { + t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state) + } + if len(state.UpdatedSkills) == 0 { + t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state) + } + if state.UpdatedAt == "" { + t.Error("state.UpdatedAt is empty, want non-empty timestamp") + } + // All locally installed official skills must appear in UpdatedSkills. + officialSet := map[string]bool{} + for _, s := range state.OfficialSkills { + officialSet[s] = true + } + for _, skill := range localSkills { + if !officialSet[skill] { + continue + } + if !containsString(state.UpdatedSkills, skill) { + t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill) + } + } + // No skill should be in SkippedDeletedSkills on cold start — there is no + // previous state recording a user deletion to preserve. + if len(state.SkippedDeletedSkills) != 0 { + t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills) + } + + // Phase 5: Verify the JSON output structure is parseable and contains + // the expected action fields for AI agent consumption. + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) + } + if env["action"] != "already_up_to_date" { + t.Errorf("action = %v, want already_up_to_date", env["action"]) + } + if env["skills_action"] != "synced" { + t.Errorf("skills_action = %v, want synced", env["skills_action"]) + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index d9cb5ab97..8cba0b7d3 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -78,12 +78,12 @@ func (r *NpmResult) CombinedOutput() string { // Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles) // are in updater_unix.go and updater_windows.go. // -// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride +// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride // / RestoreAvailableOverride for testing. type Updater struct { DetectOverride func() DetectResult NpmInstallOverride func(version string) *NpmResult - SkillsUpdateOverride func() *NpmResult + SkillsCommandOverride func(args ...string) *NpmResult VerifyOverride func(expectedVersion string) error RestoreAvailableOverride func() bool @@ -153,12 +153,27 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult { return r } -// RunSkillsUpdate installs skills, trying the .well-known source first and -// falling back to the GitHub repo on failure or timeout. -func (u *Updater) RunSkillsUpdate() *NpmResult { - if u.SkillsUpdateOverride != nil { - return u.SkillsUpdateOverride() +func (u *Updater) ListOfficialSkills() *NpmResult { + r := u.runSkillsListOfficial("https://open.feishu.cn") + if r.Err != nil { + r = u.runSkillsListOfficial("larksuite/cli") } + return r +} + +func (u *Updater) ListGlobalSkills() *NpmResult { + return u.runSkillsListGlobal() +} + +func (u *Updater) InstallSkill(nameList []string) *NpmResult { + r := u.runSkillsInstall("https://open.feishu.cn", nameList) + if r.Err != nil { + r = u.runSkillsInstall("larksuite/cli", nameList) + } + return r +} + +func (u *Updater) InstallAllSkills() *NpmResult { r := u.runSkillsAdd("https://open.feishu.cn") if r.Err != nil { r = u.runSkillsAdd("larksuite/cli") @@ -167,6 +182,28 @@ func (u *Updater) RunSkillsUpdate() *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { + return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") +} + +func (u *Updater) runSkillsListOfficial(source string) *NpmResult { + return u.runSkillsCommand("-y", "skills", "add", source, "--list") +} + +func (u *Updater) runSkillsListGlobal() *NpmResult { + return u.runSkillsCommand("-y", "skills", "ls", "-g") +} + +func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { + args := []string{"-y", "skills", "add", source, "-s"} + args = append(args, nameList...) + args = append(args, "-g", "-y") + return u.runSkillsCommand(args...) +} + +func (u *Updater) runSkillsCommand(args ...string) *NpmResult { + if u.SkillsCommandOverride != nil { + return u.SkillsCommandOverride(args...) + } r := &NpmResult{} npxPath, err := exec.LookPath("npx") if err != nil { @@ -175,7 +212,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult { } ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) defer cancel() - cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y") + cmd := exec.CommandContext(ctx, npxPath, args...) cmd.Stdout = &r.Stdout cmd.Stderr = &r.Stderr r.Err = cmd.Run() diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index f13c80b65..458f3f79b 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/larksuite/cli/internal/vfs" @@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) { t.Fatal("VerifyBinary(empty output) expected error, got nil") } } + +func TestSkillsCommandsUseExpectedArgs(t *testing.T) { + tests := []struct { + name string + run func(*Updater) *NpmResult + want string + }{ + { + name: "list official primary", + run: func(u *Updater) *NpmResult { + return u.runSkillsListOfficial("https://open.feishu.cn") + }, + want: "-y skills add https://open.feishu.cn --list", + }, + { + name: "list global", + run: func(u *Updater) *NpmResult { + return u.runSkillsListGlobal() + }, + want: "-y skills ls -g", + }, + { + name: "install skill primary", + run: func(u *Updater) *NpmResult { + return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"}) + }, + want: "-y skills add https://open.feishu.cn -s lark-mail -g -y", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script") + } + dir := t.TempDir() + script := filepath.Join(dir, "npx") + logPath := filepath.Join(dir, "npx.log") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + result := tt.run(New()) + if result.Err != nil { + t.Fatalf("command err = %v, want nil", result.Err) + } + raw, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(raw)) != tt.want { + t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want) + } + }) + } +} + +func TestListOfficialSkillsFallsBack(t *testing.T) { + called := []string{} + updater := &Updater{ + SkillsCommandOverride: func(args ...string) *NpmResult { + called = append(called, strings.Join(args, " ")) + r := &NpmResult{} + if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") { + r.Err = fmt.Errorf("primary failed") + return r + } + r.Stdout.WriteString("lark-calendar\n") + return r + }, + } + + result := updater.ListOfficialSkills() + if result.Err != nil { + t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err) + } + if len(called) != 2 { + t.Fatalf("called %d commands, want 2: %#v", len(called), called) + } + if !strings.Contains(called[1], "larksuite/cli --list") { + t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1]) + } +} diff --git a/internal/skillscheck/check.go b/internal/skillscheck/check.go index 429117a18..029a4d01f 100644 --- a/internal/skillscheck/check.go +++ b/internal/skillscheck/check.go @@ -3,46 +3,29 @@ package skillscheck -// Init runs the synchronous skills version check. Stores a StaleNotice -// when the local stamp records a version that does not match -// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute(); -// zero network, zero subprocess — only a local stamp file read. +import "strings" + +// Init runs the synchronous skills version check. Stores a StaleNotice when +// the local skills state records a version that does not match currentVersion. +// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero +// subprocess — only a local state file read. // // Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver, // LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out). -// -// Failure modes (all → no notice, no nag): -// - shouldSkip rule met -// - ReadStamp returns an I/O error other than ENOENT -// - Stamp matches currentVersion (in-sync) -// - Stamp is missing (cold start) — only users who ran `lark-cli update` -// opt into drift tracking; npx-only installs are intentionally silent. func Init(currentVersion string) { - // Clear any stale notice from a prior call so early returns below - // (skip rules / read errors / cold start / in-sync) leave pending == nil - // instead of preserving a stale value from a previous Init invocation. SetPending(nil) if shouldSkip(currentVersion) { return } - stamp, err := ReadStamp() - if err != nil { - // Fail closed — don't nag for a transient FS problem. + version, ok := ReadSyncedVersion() + if !ok { return } - if stamp == "" { - // Cold start: the stamp is written exclusively by `lark-cli update` - // (runSkillsAndStamp). Users who installed skills via - // `npx skills add larksuite/cli -g` have no stamp yet — they must - // not be nagged with "skills not installed", since the on-disk - // skills directory may already be fully populated. - return - } - if stamp == currentVersion { + if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") { return } SetPending(&StaleNotice{ - Current: stamp, // guaranteed non-empty under the new contract + Current: version, Target: currentVersion, }) } diff --git a/internal/skillscheck/check_test.go b/internal/skillscheck/check_test.go index 64525bc5a..2674d5424 100644 --- a/internal/skillscheck/check_test.go +++ b/internal/skillscheck/check_test.go @@ -18,9 +18,8 @@ func resetPending(t *testing.T) { func TestInit_InSync_NoNotice(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.21"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } Init("1.0.21") @@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) { } } -func TestInit_Drift_NoticeWithStampVersion(t *testing.T) { +func TestInit_NormalizedVersion_NoNotice(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.20"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil { + t.Fatal(err) + } + Init("v1.0.21") + if got := GetPending(); got != nil { + t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got) + } +} + +func TestInit_Drift_NoticeWithStateVersion(t *testing.T) { + clearSkillsSkipEnv(t) + resetPending(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } Init("1.0.21") @@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - // Even with an empty config dir (no stamp), DEV version should skip - // the check entirely and never emit a notice. Init("DEV") if got := GetPending(); got != nil { t.Errorf("GetPending() = %+v, want nil (skip rules met)", got) } } -func TestInit_ReadStampError_FailsClosed(t *testing.T) { +func TestInit_ReadStateError_FailsClosed(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - // Make the stamp path a directory so vfs.ReadFile returns a - // non-ENOENT I/O error. - if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil { t.Fatal(err) } Init("1.0.21") diff --git a/internal/skillscheck/notice.go b/internal/skillscheck/notice.go index b1f972218..c1425fbb7 100644 --- a/internal/skillscheck/notice.go +++ b/internal/skillscheck/notice.go @@ -3,9 +3,8 @@ // Package skillscheck verifies that the locally installed lark-cli // skills are in sync with the running binary version, by comparing -// the current binary version against a stamp file written when skills -// are last synced (by `lark-cli update`). On mismatch it stores a -// notice for injection into JSON envelopes via output.PendingNotice. +// the current binary version against skills-state.json. On mismatch it +// stores a notice for injection into JSON envelopes via output.PendingNotice. package skillscheck import ( @@ -26,8 +25,7 @@ type StaleNotice struct { // Message returns a single-line, AI-agent-parseable description of the // drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message // in style ("..., run: lark-cli update" suffix). Current is guaranteed -// non-empty because Init only emits a StaleNotice for the drift case -// (stamp present and != binary version). +// non-empty because Init only emits a StaleNotice for the drift case. func (s *StaleNotice) Message() string { return fmt.Sprintf( "lark-cli skills %s out of sync with binary %s, run: lark-cli update", diff --git a/internal/skillscheck/stamp.go b/internal/skillscheck/stamp.go deleted file mode 100644 index 052e331c9..000000000 --- a/internal/skillscheck/stamp.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package skillscheck - -import ( - "errors" - "io/fs" - "path/filepath" - "strings" - - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" -) - -const stampFile = "skills.stamp" - -// stampPath returns ~/.lark-cli/skills.stamp. -// Uses the BASE config dir (not workspace-aware) because skills install -// globally via `npx -g`; per-workspace tracking would produce false -// drift signals when switching workspaces. -func stampPath() string { - return filepath.Join(core.GetBaseConfigDir(), stampFile) -} - -// ReadStamp returns the version recorded in the stamp file. Returns -// ("", nil) when the file does not exist (interpreted as "never synced"). -// Other I/O errors are returned as-is so callers can fail closed. -func ReadStamp() (string, error) { - data, err := vfs.ReadFile(stampPath()) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return "", nil - } - return "", err - } - return strings.TrimSpace(string(data)), nil -} - -// WriteStamp records `version` as the last successfully synced skills -// version. Atomic via tmp + rename (validate.AtomicWrite). Creates -// the base config directory if it does not exist. -func WriteStamp(version string) error { - if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil { - return err - } - return validate.AtomicWrite(stampPath(), []byte(version), 0o644) -} diff --git a/internal/skillscheck/stamp_test.go b/internal/skillscheck/stamp_test.go deleted file mode 100644 index 8e60dfbb4..000000000 --- a/internal/skillscheck/stamp_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package skillscheck - -import ( - "os" - "path/filepath" - "testing" -) - -func TestReadStamp_Missing(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - got, err := ReadStamp() - if err != nil { - t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err) - } - if got != "" { - t.Errorf("ReadStamp() = %q, want \"\" for missing file", got) - } -} - -func TestReadStamp_Normal(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil { - t.Fatal(err) - } - got, err := ReadStamp() - if err != nil || got != "1.0.21" { - t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err) - } -} - -func TestReadStamp_TrailingNewlineTolerated(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil { - t.Fatal(err) - } - got, _ := ReadStamp() - if got != "1.0.21" { - t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got) - } -} - -func TestReadStamp_EmptyFile(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil { - t.Fatal(err) - } - got, err := ReadStamp() - if err != nil || got != "" { - t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err) - } -} - -func TestWriteStamp_CreatesDir(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nested") - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.21"); err != nil { - t.Fatalf("WriteStamp() = %v, want nil", err) - } - got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp")) - if string(got) != "1.0.21" { - t.Errorf("file content = %q, want \"1.0.21\"", string(got)) - } -} - -func TestWriteStamp_OverwritesExisting(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.20"); err != nil { - t.Fatal(err) - } - if err := WriteStamp("1.0.21"); err != nil { - t.Fatal(err) - } - got, _ := ReadStamp() - if got != "1.0.21" { - t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got) - } -} - -func TestWriteStamp_NoTrailingNewline(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.21"); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp")) - if string(raw) != "1.0.21" { - t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw)) - } -} - -// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error -// when the base config dir cannot be created (parent path is a regular file). -func TestWriteStamp_MkdirAllFailure(t *testing.T) { - tmp := t.TempDir() - blocker := filepath.Join(tmp, "blocker") - // Create a regular file where MkdirAll wants to create a directory. - if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil { - t.Fatal(err) - } - // Point the config dir at a path UNDER the regular file — MkdirAll must fail. - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child")) - - if err := WriteStamp("1.0.21"); err == nil { - t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure") - } -} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go new file mode 100644 index 000000000..eddab1cf3 --- /dev/null +++ b/internal/skillscheck/state.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +const ( + stateFile = "skills-state.json" +) + +var ErrUnreadableState = errors.New("skills state is unreadable") + +type SkillsState struct { + Version string `json:"version"` + OfficialSkills []string `json:"official_skills"` + UpdatedSkills []string `json:"updated_skills"` + AddedOfficialSkills []string `json:"added_official_skills"` + SkippedDeletedSkills []string `json:"skipped_deleted_skills"` + UpdatedAt string `json:"updated_at"` +} + +func statePath() string { + return filepath.Join(core.GetBaseConfigDir(), stateFile) +} + +func ReadState() (*SkillsState, bool, error) { + data, err := vfs.ReadFile(statePath()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err) + } + + var state SkillsState + if err := json.Unmarshal(data, &state); err != nil { + return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err) + } + return &state, true, nil +} + +func WriteState(state SkillsState) error { + state.ensureNonNilSlices() + + if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644) +} + +func ReadSyncedVersion() (string, bool) { + state, ok, err := ReadState() + if err != nil || !ok || state.Version == "" { + return "", false + } + return state.Version, true +} + +func (s *SkillsState) ensureNonNilSlices() { + if s.OfficialSkills == nil { + s.OfficialSkills = []string{} + } + if s.UpdatedSkills == nil { + s.UpdatedSkills = []string{} + } + if s.AddedOfficialSkills == nil { + s.AddedOfficialSkills = []string{} + } + if s.SkippedDeletedSkills == nil { + s.SkippedDeletedSkills = []string{} + } +} diff --git a/internal/skillscheck/state_test.go b/internal/skillscheck/state_test.go new file mode 100644 index 000000000..d69b74635 --- /dev/null +++ b/internal/skillscheck/state_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestReadState_Missing(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + state, ok, err := ReadState() + if err != nil { + t.Fatalf("ReadState() err = %v, want nil for missing file", err) + } + if ok { + t.Fatal("ReadState() ok = true, want false for missing file") + } + if state != nil { + t.Fatalf("ReadState() state = %#v, want nil for missing file", state) + } +} + +func TestReadState_Valid(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + want := SkillsState{ + Version: "1.2.3", + OfficialSkills: []string{"lark-doc", "lark-im"}, + UpdatedSkills: []string{"lark-doc"}, + AddedOfficialSkills: []string{"lark-task"}, + SkippedDeletedSkills: []string{"custom-skill"}, + UpdatedAt: "2026-05-18T10:00:00Z", + } + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil { + t.Fatal(err) + } + + got, ok, err := ReadState() + if err != nil { + t.Fatalf("ReadState() err = %v, want nil", err) + } + if !ok { + t.Fatal("ReadState() ok = false, want true") + } + if got == nil { + t.Fatal("ReadState() state = nil, want state") + } + if !reflect.DeepEqual(*got, want) { + t.Fatalf("ReadState() state = %#v, want %#v", *got, want) + } +} + +func TestReadState_CorruptStateUnreadable(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(`{"version":`), 0o644); err != nil { + t.Fatal(err) + } + + state, ok, err := ReadState() + if !errors.Is(err, ErrUnreadableState) { + t.Fatalf("ReadState() err = %v, want ErrUnreadableState", err) + } + if ok { + t.Fatal("ReadState() ok = true, want false") + } + if state != nil { + t.Fatalf("ReadState() state = %#v, want nil", state) + } +} + +func TestWriteState_CreatesDirAndWritesState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + state := SkillsState{ + Version: "1.2.3", + UpdatedAt: "2026-05-18T10:00:00Z", + } + if err := WriteState(state); err != nil { + t.Fatalf("WriteState() err = %v, want nil", err) + } + + raw, err := os.ReadFile(filepath.Join(dir, stateFile)) + if err != nil { + t.Fatal(err) + } + var got SkillsState + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("written state is invalid JSON: %v", err) + } + if got.Version != state.Version { + t.Fatalf("version = %q, want %q", got.Version, state.Version) + } + if got.OfficialSkills == nil { + t.Fatal("official_skills decoded as nil, want empty slice") + } + if got.UpdatedSkills == nil { + t.Fatal("updated_skills decoded as nil, want empty slice") + } + if got.AddedOfficialSkills == nil { + t.Fatal("added_skills decoded as nil, want empty slice") + } + if got.SkippedDeletedSkills == nil { + t.Fatal("skipped_deleted_skills decoded as nil, want empty slice") + } +} + +func TestReadSyncedVersionFromState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + if got, ok := ReadSyncedVersion(); ok || got != "" { + t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok) + } + if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil { + t.Fatal(err) + } + if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" { + t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok) + } + if err := WriteState(SkillsState{}); err != nil { + t.Fatal(err) + } + if got, ok := ReadSyncedVersion(); ok || got != "" { + t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok) + } +} diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go new file mode 100644 index 000000000..f2d2a2cf8 --- /dev/null +++ b/internal/skillscheck/sync.go @@ -0,0 +1,399 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" + + "github.com/larksuite/cli/internal/selfupdate" +) + +var ( + skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`) + ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) +) + +type SyncInput struct { + Version string + OfficialSkills []string + LocalSkills []string + PreviousState *SkillsState + StateReadable bool + Force bool +} + +type SyncPlan struct { + Version string + OfficialSkills []string + ToUpdate []string + Added []string + SkippedDeleted []string +} + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +func ParseSkillsList(text string) []string { + text = stripANSI(text) + lines := strings.Split(text, "\n") + + // Detect format type + hasGlobalSkills := strings.Contains(text, "Global Skills") + hasAvailableSkills := strings.Contains(text, "Available Skills") + + if hasGlobalSkills { + // Format 1: locally installed skills list from "npx -y skills ls -g" + return parseGlobalSkillsList(lines) + } else if hasAvailableSkills { + // Format 2: official skills list from "npx -y skills add ... --list" + return parseOfficialSkillsList(lines) + } + return nil +} + +// parseGlobalSkillsList parses the output of "npx -y skills ls -g" +func parseGlobalSkillsList(lines []string) []string { + seen := map[string]bool{} + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Skip header + if strings.HasPrefix(trimmed, "Global Skills") { + continue + } + + // Skip empty lines + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "Tip:") { + continue + } + + // Skip indented lines (Agents: ...) + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + continue + } + + // Extract skill name, format is typically "skill-name /path/to/skill" + parts := strings.Fields(trimmed) + if len(parts) == 0 { + continue + } + + candidate := parts[0] + + // Validate and add + if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") { + continue + } + if !skillNamePattern.MatchString(candidate) { + continue + } + if at := strings.Index(candidate, "@"); at > 0 { + candidate = candidate[:at] + } + seen[candidate] = true + } + + return sortedKeys(seen) +} + +// parseOfficialSkillsList parses the output of "npx -y skills add ... --list" +func parseOfficialSkillsList(lines []string) []string { + seen := map[string]bool{} + inAvailableSection := false + + for _, line := range lines { + // Check if we've reached the "Available Skills" section + if strings.Contains(line, "Available Skills") { + inAvailableSection = true + continue + } + + if !inAvailableSection { + continue + } + + // Process lines containing "│", e.g. " │ lark-approval " + if strings.Contains(line, "│") { + // Remove all "│" characters and spaces, extract the first valid token in order + parts := strings.FieldsFunc(line, func(r rune) bool { + return r == '│' || r == ' ' + }) + + if len(parts) > 0 { + candidate := parts[0] + // Check if it's a valid official skill name + if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) { + seen[candidate] = true + } + } + } + } + + return sortedKeys(seen) +} + +func PlanSync(input SyncInput) SyncPlan { + official := uniqueSorted(input.OfficialSkills) + if input.Force { + return SyncPlan{ + Version: input.Version, + OfficialSkills: official, + ToUpdate: official, + Added: []string{}, + SkippedDeleted: []string{}, + } + } + + officialSet := toSet(official) + installedOfficial := intersection(input.LocalSkills, officialSet) + + previousOfficial := []string{} + if input.StateReadable && input.PreviousState != nil { + previousOfficial = input.PreviousState.OfficialSkills + } + previousSet := toSet(previousOfficial) + + newAddedOfficial := []string{} + for _, skill := range official { + if !previousSet[skill] { + newAddedOfficial = append(newAddedOfficial, skill) + } + } + + updateSet := toSet(installedOfficial) + for _, skill := range newAddedOfficial { + updateSet[skill] = true + } + toUpdate := sortedKeys(updateSet) + updateSet = toSet(toUpdate) + + skipped := []string{} + for _, skill := range official { + if !updateSet[skill] { + skipped = append(skipped, skill) + } + } + + return SyncPlan{ + Version: input.Version, + OfficialSkills: official, + ToUpdate: toUpdate, + Added: uniqueSorted(newAddedOfficial), + SkippedDeleted: skipped, + } +} + +type SkillsRunner interface { + ListOfficialSkills() *selfupdate.NpmResult + ListGlobalSkills() *selfupdate.NpmResult + InstallSkill(nameList []string) *selfupdate.NpmResult + InstallAllSkills() *selfupdate.NpmResult +} + +type SyncOptions struct { + Version string + Force bool + Runner SkillsRunner + Now func() time.Time +} + +type SyncResult struct { + Action string + Official []string + Updated []string + Added []string + SkippedDeleted []string + Failed []string + Err error + Detail string + Force bool +} + +func SyncSkills(opts SyncOptions) *SyncResult { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Runner == nil { + return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")} + } + + // --- Step 1: List official skills --- + officialResult := opts.Runner.ListOfficialSkills() + if officialResult == nil || officialResult.Err != nil { + return fallbackFullInstall(opts, resultDetail(officialResult), nil) + } + official := ParseSkillsList(officialResult.Stdout.String()) + + if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" { + return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil) + } + + // --- Step 2: List local (installed) skills --- + local := []string{} + localResult := opts.Runner.ListGlobalSkills() + if localResult != nil && localResult.Err == nil { + local = ParseSkillsList(localResult.Stdout.String()) + } + + // --- Step 3: Read previous state --- + previous, readable, err := ReadState() + if err != nil { + readable = false + previous = nil + } + + plan := PlanSync(SyncInput{ + Version: opts.Version, + OfficialSkills: official, + LocalSkills: local, + PreviousState: previous, + StateReadable: readable, + Force: opts.Force, + }) + + result := &SyncResult{ + Action: "synced", + Official: plan.OfficialSkills, + Updated: plan.ToUpdate, + Added: plan.Added, + SkippedDeleted: plan.SkippedDeleted, + Force: opts.Force, + } + + if len(plan.ToUpdate) > 0 { + installResult := opts.Runner.InstallSkill(plan.ToUpdate) + if installResult == nil || installResult.Err != nil { + return fallbackFullInstall(opts, resultDetail(installResult), official) + } + } + + state := SkillsState{ + Version: opts.Version, + OfficialSkills: plan.OfficialSkills, + UpdatedSkills: plan.ToUpdate, + AddedOfficialSkills: plan.Added, + SkippedDeletedSkills: plan.SkippedDeleted, + UpdatedAt: opts.Now().UTC().Format(time.RFC3339), + } + if err := WriteState(state); err != nil { + result.Action = "failed" + result.Err = fmt.Errorf("skills synced but state not written: %w", err) + return result + } + + return result +} + +// fallbackFullInstall performs a full skills install (npx -y skills add -g -y) +// when incremental sync is not possible. On success it writes a state file so that +// subsequent syncs can use incremental mode. When official is non-nil the state +// records the full official list; otherwise a minimal state (version only) is +// written to break the fallback loop. +func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult { + installResult := opts.Runner.InstallAllSkills() + if installResult == nil { + return &SyncResult{ + Action: "fallback_failed", + Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason), + Detail: reason, + Force: opts.Force, + } + } + if installResult.Err != nil { + return &SyncResult{ + Action: "fallback_failed", + Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason), + Detail: reason + "\n" + resultDetail(installResult), + Force: opts.Force, + } + } + + state := SkillsState{ + Version: opts.Version, + OfficialSkills: official, + UpdatedSkills: official, + AddedOfficialSkills: official, + SkippedDeletedSkills: []string{}, + UpdatedAt: opts.Now().UTC().Format(time.RFC3339), + } + if writeErr := WriteState(state); writeErr != nil { + return &SyncResult{ + Action: "fallback_synced", + Official: official, + Updated: official, + Added: official, + SkippedDeleted: []string{}, + Detail: reason + "\nstate write failed: " + writeErr.Error(), + Force: opts.Force, + } + } + + return &SyncResult{ + Action: "fallback_synced", + Official: official, + Updated: official, + Added: official, + SkippedDeleted: []string{}, + Detail: reason, + Force: opts.Force, + } +} + +func resultDetail(result *selfupdate.NpmResult) string { + if result == nil { + return "" + } + parts := []string{} + if output := strings.TrimSpace(result.CombinedOutput()); output != "" { + parts = append(parts, output) + } + if result.Err != nil { + parts = append(parts, result.Err.Error()) + } + return strings.Join(parts, "\n") +} + +func uniqueSorted(values []string) []string { + return sortedKeys(toSet(values)) +} + +func toSet(values []string) map[string]bool { + out := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out[value] = true + } + } + return out +} + +// result = { x | x ∈ values ∧ x ∈ allowed } +func intersection(values []string, allowed map[string]bool) []string { + out := map[string]bool{} + for _, value := range values { + if allowed[value] { + out[value] = true + } + } + return sortedKeys(out) +} + +func sortedKeys(values map[string]bool) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go new file mode 100644 index 000000000..18a2802c6 --- /dev/null +++ b/internal/skillscheck/sync_test.go @@ -0,0 +1,517 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/selfupdate" +) + +func TestParseSkillsListIgnoresUnsupportedFormat(t *testing.T) { + input := `Installed skills: +- lark-calendar +- lark-mail +lark-im +custom-skill +lark-base@1.0.0 +lark-cli-harness:dev@0.1.0 +` + got := ParseSkillsList(input) + if len(got) != 0 { + t.Fatalf("ParseSkillsList() = %#v, want empty result for unsupported format", got) + } +} + +func TestParseGlobalSkillsList(t *testing.T) { + input := `Global Skills + +lark-approval ~/.agents/skills/lark-approval + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +lark-attendance ~/.agents/skills/lark-attendance + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +lark-base ~/.agents/skills/lark-base + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +lark-calendar ~/.agents/skills/lark-calendar + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +dogfood ~/.hermes/skills/dogfood + Agents: Hermes Agent +yuanbao ~/.hermes/skills/yuanbao + Agents: Hermes Agent +` + got := ParseSkillsList(input) + want := []string{"dogfood", "lark-approval", "lark-attendance", "lark-base", "lark-calendar", "yuanbao"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ParseSkillsList() (Global Skills) = %#v, want %#v", got, want) + } +} + +func TestParseGlobalSkillsListWithANSI(t *testing.T) { + input := "\x1b[1mGlobal Skills\x1b[0m\n\n" + + "\x1b[36mlark-calendar\x1b[0m \x1b[38;5;102m~/.agents/skills/lark-calendar\x1b[0m\n" + + " \x1b[38;5;102mAgents:\x1b[0m TRAE CN, TRAE +3 more\n" + + "\x1b[36mdogfood\x1b[0m \x1b[38;5;102m~/.hermes/skills/dogfood\x1b[0m\n" + + " \x1b[38;5;102mAgents:\x1b[0m Hermes Agent\n" + + "\nTip: Use the -y flag to run in non-interactive mode (for CI and AI agents).\n" + got := ParseSkillsList(input) + want := []string{"dogfood", "lark-calendar"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ParseSkillsList() (ANSI Global Skills) = %#v, want %#v", got, want) + } +} + +func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) { + previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}} + got := PlanSync(SyncInput{ + Version: "1.0.33", + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"}, + LocalSkills: []string{"lark-calendar", "lark-custom"}, + PreviousState: previous, + StateReadable: true, + Force: false, + }) + + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"}) + assertStrings(t, got.Added, []string{"lark-new"}) + assertStrings(t, got.SkippedDeleted, []string{"lark-mail"}) +} + +func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) { + got := PlanSync(SyncInput{ + Version: "1.0.33", + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"}, + LocalSkills: []string{"lark-calendar"}, + StateReadable: false, + Force: false, + }) + + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, got.SkippedDeleted, []string{}) +} + +func TestPlanForceRestoresAllOfficial(t *testing.T) { + got := PlanSync(SyncInput{ + Version: "1.0.33", + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"}, + LocalSkills: []string{"lark-calendar"}, + PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}, + StateReadable: true, + Force: true, + }) + + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, got.Added, []string{}) + assertStrings(t, got.SkippedDeleted, []string{}) +} + +type fakeSkillsRunner struct { + officialOut string + globalOut string + officialErr error + globalErr error + installErr error + installAllErr error + installed [][]string + installedAll int +} + +func officialSkillsOutput(names ...string) string { + var b strings.Builder + b.WriteString("Available Skills\n") + for _, name := range names { + b.WriteString("│ ") + b.WriteString(name) + b.WriteString("\n") + } + return b.String() +} + +func globalSkillsOutput(names ...string) string { + var b strings.Builder + b.WriteString("Global Skills\n\n") + for _, name := range names { + b.WriteString(name) + b.WriteString(" ~/.agents/skills/") + b.WriteString(name) + b.WriteString("\n Agents: Claude Code\n") + } + return b.String() +} + +func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stdout.WriteString(f.officialOut) + r.Err = f.officialErr + return r +} + +func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stdout.WriteString(f.globalOut) + r.Err = f.globalErr + return r +} + +func (f *fakeSkillsRunner) InstallSkill(nameList []string) *selfupdate.NpmResult { + f.installed = append(f.installed, nameList) + r := &selfupdate.NpmResult{} + r.Err = f.installErr + return r +} + +func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult { + f.installedAll++ + r := &selfupdate.NpmResult{} + r.Err = f.installAllErr + return r +} + +func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := WriteState(SkillsState{ + Version: "1.0.30", + OfficialSkills: []string{"lark-calendar", "lark-mail"}, + UpdatedAt: "2026-05-18T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"), + globalOut: globalSkillsOutput("lark-calendar", "lark-custom"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Runner: runner, + Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) }, + }) + + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"}) + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"}) + assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"}) + assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"}) + if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) { + t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err) + } +} + +func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1", runner.installedAll) + } + if len(runner.installed) != 0 { + t.Fatalf("installed = %#v, want no incremental installs", runner.installed) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + assertStrings(t, state.OfficialSkills, []string{}) +} + +func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialErr: fmt.Errorf("list failed"), + installAllErr: fmt.Errorf("full install failed"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_failed" { + t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action) + } + if result.Err == nil { + t.Fatalf("SyncSkills() err = nil, want error") + } + if !strings.Contains(result.Err.Error(), "full skills install failed") { + t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err) + } +} + +func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalErr: fmt.Errorf("global list failed"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err) + } + if result.Action != "synced" { + t.Fatalf("SyncSkills() action = %q, want synced", result.Action) + } + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) +} + +func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: "Some unrecognized output format\n", + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err) + } + if result.Action != "synced" { + t.Fatalf("SyncSkills() action = %q, want synced", result.Action) + } + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll) + } + if len(runner.installed) != 1 { + t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed)) + } +} + +func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if len(runner.installed) != 1 { + t.Fatalf("installed = %d calls, want 1", len(runner.installed)) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"}) +} + +func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: fmt.Errorf("full install boom"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_failed" { + t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action) + } + if result.Err == nil { + t.Fatalf("SyncSkills() err = nil, want error") + } + if !strings.Contains(result.Detail, "incremental boom") { + t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail) + } + if !strings.Contains(result.Err.Error(), "full skills install failed") { + t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err) + } +} + +func TestSyncSkills_NilRunnerFails(t *testing.T) { + result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now}) + if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") { + t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err) + } +} + +func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: "Some unrecognized output format\n", + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1", runner.installedAll) + } +} + +func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: "Some unrecognized output format\n", + installAllErr: fmt.Errorf("full install failed"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_failed" { + t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action) + } + if result.Err == nil { + t.Fatalf("SyncSkills() err = nil, want error") + } +} + +func assertStrings(t *testing.T, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: "Some unrecognized output format\n", + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + assertStrings(t, state.OfficialSkills, []string{}) + assertStrings(t, state.UpdatedSkills, []string{}) + assertStrings(t, state.AddedOfficialSkills, []string{}) +} + +func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, state.AddedOfficialSkills, []string{"lark-calendar", "lark-mail"}) +} + +func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) + if !strings.Contains(result.Detail, "incremental boom") { + t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail) + } +} + +func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, + } + + result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result1.Action != "fallback_synced" { + t.Fatalf("first sync: action = %q, want fallback_synced", result1.Action) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() after first sync = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + + runner2 := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + } + result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now}) + if result2.Action != "synced" { + t.Fatalf("second sync: action = %q, want synced (no fallback loop)", result2.Action) + } + if runner2.installedAll != 0 { + t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll) + } +} From f00261da9fefe94633b81c49ada3b366fd4391d3 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Tue, 26 May 2026 19:51:47 +0800 Subject: [PATCH 08/62] fix(drive): support doubao drive inspect URL variants (#1106) --- shortcuts/common/resource_url.go | 3 + shortcuts/common/resource_url_test.go | 3 + shortcuts/drive/drive_inspect_test.go | 115 ++++++++++++++++++ .../drive/drive_inspect_dryrun_test.go | 18 +++ 4 files changed, 139 insertions(+) diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 69345ea90..29ec31c10 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -73,6 +73,9 @@ var urlPathToType = []struct { Type string }{ {"/drive/folder/", "folder"}, + {"/drive/file/", "file"}, + {"/drive/shr/", "folder"}, + {"/chat/drive/", "folder"}, {"/docx/", "docx"}, {"/doc/", "doc"}, {"/sheets/", "sheet"}, diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go index baa1165f1..c0109fe9c 100644 --- a/shortcuts/common/resource_url_test.go +++ b/shortcuts/common/resource_url_test.go @@ -28,6 +28,9 @@ func TestParseResourceURL(t *testing.T) { {"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true}, {"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true}, {"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true}, + {"file via /drive/file/", "https://feishu.doubao.com/drive/file/boxcnABC", "file", "boxcnABC", true}, + {"folder via /chat/drive/", "https://feishu.doubao.com/chat/drive/fldcnABC", "folder", "fldcnABC", true}, + {"folder via /drive/shr/", "https://feishu.doubao.com/drive/shr/fldcnABC", "folder", "fldcnABC", true}, {"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true}, {"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true}, diff --git a/shortcuts/drive/drive_inspect_test.go b/shortcuts/drive/drive_inspect_test.go index a40cc399d..19da7ecbf 100644 --- a/shortcuts/drive/drive_inspect_test.go +++ b/shortcuts/drive/drive_inspect_test.go @@ -109,6 +109,45 @@ func TestDriveInspectValidate_ValidWikiURL(t *testing.T) { } } +func TestDriveInspectValidate_ValidDoubaoDriveFileURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := DriveInspect.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDriveInspectValidate_ValidDoubaoChatDriveFolderURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/chat/drive/fldcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := DriveInspect.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDriveInspectValidate_ValidDoubaoDriveShareFolderURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := DriveInspect.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + // --- DryRun tests --- func TestDriveInspectDryRun_DocxURL(t *testing.T) { @@ -235,6 +274,82 @@ func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) { } } +func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + dry := DriveInspect.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run: %v", err) + } + reqDocs, ok := got.API[0].Body["request_docs"].([]interface{}) + if !ok || len(reqDocs) != 1 { + t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"]) + } + doc, _ := reqDocs[0].(map[string]interface{}) + if doc["doc_token"] != "boxcnABC" { + t.Errorf("doc_token = %v, want boxcnABC", doc["doc_token"]) + } + if doc["doc_type"] != "file" { + t.Errorf("doc_type = %v, want file", doc["doc_type"]) + } +} + +func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + dry := DriveInspect.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run: %v", err) + } + reqDocs, ok := got.API[0].Body["request_docs"].([]interface{}) + if !ok || len(reqDocs) != 1 { + t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"]) + } + doc, _ := reqDocs[0].(map[string]interface{}) + if doc["doc_token"] != "fldcnABC" { + t.Errorf("doc_token = %v, want fldcnABC", doc["doc_token"]) + } + if doc["doc_type"] != "folder" { + t.Errorf("doc_type = %v, want folder", doc["doc_type"]) + } +} + // --- Execute tests --- func TestDriveInspectExecute_DocxURL(t *testing.T) { diff --git a/tests/cli_e2e/drive/drive_inspect_dryrun_test.go b/tests/cli_e2e/drive/drive_inspect_dryrun_test.go index 286d6b549..8d375ad33 100644 --- a/tests/cli_e2e/drive/drive_inspect_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_inspect_dryrun_test.go @@ -45,12 +45,30 @@ func TestDriveInspectDryRun_FileURL(t *testing.T) { assertOneStepBatchQuery(t, result) } +func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) { + setDriveInspectE2EEnv(t) + result := runInspectDryRun(t, "https://feishu.doubao.com/drive/file/boxcnDryRunE2E") + assertOneStepBatchQuery(t, result) +} + func TestDriveInspectDryRun_FolderURL(t *testing.T) { setDriveInspectE2EEnv(t) result := runInspectDryRun(t, "https://xxx.feishu.cn/drive/folder/fldcnDryRunE2E") assertOneStepBatchQuery(t, result) } +func TestDriveInspectDryRun_DoubaoChatDriveFolderURL(t *testing.T) { + setDriveInspectE2EEnv(t) + result := runInspectDryRun(t, "https://feishu.doubao.com/chat/drive/fldcnDryRunE2E") + assertOneStepBatchQuery(t, result) +} + +func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) { + setDriveInspectE2EEnv(t) + result := runInspectDryRun(t, "https://feishu.doubao.com/drive/shr/fldcnDryRunE2E") + assertOneStepBatchQuery(t, result) +} + func TestDriveInspectDryRun_MindnoteURL(t *testing.T) { setDriveInspectE2EEnv(t) result := runInspectDryRun(t, "https://xxx.feishu.cn/mindnote/mncnDryRunE2E") From b7835619653e401b485084417bbc0346e3271ed5 Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Tue, 26 May 2026 20:54:54 +0800 Subject: [PATCH 09/62] chore(release): v1.0.41 (#1108) Change-Id: I3559c31109a5a5a7c3cfc3e54f60aff4043bfefc --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a07fd89b..95f3c1f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to this project will be documented in this file. +## [v1.0.41] - 2026-05-26 + +### Features + +- **minutes**: Add minutes edit shortcuts (#1036) +- **minutes**: Get minutes keywords (#1079) +- **slides**: Support importing pptx as slides (#1068) +- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085) +- **errors**: Add structured CLI error contract (#984) +- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072) + +### Bug Fixes + +- **drive**: Support doubao drive inspect URL variants (#1106) +- **skills**: Sync skills incrementally during update (#1042) +- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087) +- **common**: Escape special chars in multipart form filenames (#1037) +- **auth**: Remove fenced code block guidance from auth URL output hints (#1088) + +### Documentation + +- **skills**: Fix agent routing for doubao.com URLs (#1082) +- **task**: Require `--complete=false` for pending standup summaries (#1101) +- **base**: Document UI-only field settings (#1078) +- **contributing**: Clarify contributor guidance (#1096) + ## [v1.0.40] - 2026-05-25 ### Features @@ -860,6 +886,7 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41 [v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40 [v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39 [v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38 diff --git a/package.json b/package.json index 2cd1473e6..ad210d2e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.40", + "version": "1.0.41", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" From 68d78d5067d3e17d022ad6317494727d038af82a Mon Sep 17 00:00:00 2001 From: syh-cpdsss Date: Tue, 26 May 2026 21:18:08 +0800 Subject: [PATCH 10/62] feat: better whiteboard svg/mermaid instructions (#1097) * feat: better whiteboard svg/mermaid instructions Change-Id: I615cdf405840fca6bbaea1f95a37ec655fd6aedf * fix: PR issue Change-Id: I0a8ee556f33f0ba65812a3d73fc9c4a5266abbcd --- .../references/lark-doc-whiteboard.md | 144 ++++++++++++------ .../style/lark-doc-create-workflow.md | 12 +- .../references/style/lark-doc-style.md | 49 ++---- .../style/lark-doc-update-workflow.md | 11 +- 4 files changed, 126 insertions(+), 90 deletions(-) diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md index c6f9b04b2..a4d66409e 100644 --- a/skills/lark-doc/references/lark-doc-whiteboard.md +++ b/skills/lark-doc/references/lark-doc-whiteboard.md @@ -4,56 +4,133 @@ ## 两个 Skill 的职责边界 -| Skill | 核心职责 | 约束 | -|------|------|------| -| `lark-doc` | 识别画板机会、判断简单/复杂、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容;简单图不需要读取 `lark-whiteboard` | -| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅复杂图或已有画板更新时由独立 SubAgent 读取 | +| Skill | 核心职责 | 约束 | +|-------------------|-----------------------------------------------------------|---------------------------------| +| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; | +| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 | ## 画板优先规则 写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。 -同一篇文档可以有多个画板。优先多个聚焦画板,而不是把所有信息塞进一张大图。 +同一篇文档可以有多个画板。优先设计多个聚焦画板,而不是把所有信息塞进一张大图。 ## 文档与画板协同流程 ### 步骤 1:识别画板机会 -| 场景 | 入口 | -|------|------| -| 文档中需要插入简单新画板 | 走步骤 2A | -| 文档中需要插入复杂新画板 | 走步骤 2B | -| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B | -| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 | +| 场景 | 入口 | +|-------------------------|-----------------------------------------------------------| +| 文档中需要思维导图、时序图、类图、饼图、甘特图 | 步骤 2A:使用 mermaid 插入图表 | +| 文档中需要插入其他图表/自定义图形 | 步骤 2B: 使用 SVG 插入图表 | +| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B | +| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 | -简单图判定:节点少、静态、布局可控、适合一个完整自包含 SVG 表达,例如小型流程、2-3 方对比、小型状态机、简单时间线或小型示意图。 +> [!IMPORTANT] +> ⚠️ **分别对每个图表进行决策** -复杂图判定:节点多、跨泳道/跨系统、需要自动布局或精细排版、包含数据图表、组织架构、复杂架构、复杂依赖、已有画板更新,或需要 `lark-whiteboard` 的渲染验证。 +如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B +中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动 +SubAgent 插入 SVG。 -### 步骤 2A:简单图 — SubAgent 直接插入 SVG 画板 +建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表 + +### 步骤 2A: 使用 mermaid 插入图表 + +```xml + + + mermaid 代码... + +``` + +### 步骤 2B: SubAgent 使用 SVG 插入图表 主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入: ```xml -... + + + ... + + ``` -简单图 SubAgent 的最小上下文: +Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南: + - doc token、插入位置(标题 / block_id / command) - 图表目标、受众、源段落或数据 - 要求读取 `lark-doc-xml.md`;不需要读取 `lark-whiteboard` - SVG 必须完整自包含:包含 `` 根节点和 `viewBox`,不引用外部图片、脚本、远程资源 -### 步骤 2B:复杂图 — 先创建空白画板 +#### 画板 SVG 设计指南 -- 主 Agent 使用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 ``。 -- 从 v2 响应的 `data.document.new_blocks[]` 中读取 `block_type == "whiteboard"` 的 `block_token` 作为 board_token。 +使用 SVG 插入画板时,最终交付是**画板跨越重排渲染的节点**(你写 SVG → 画板解析) +**核心心智纠正 (重要)**: -### 步骤 3B:复杂图或已有画板 — 启动 lark-whiteboard SubAgent +- 大多数 AI 如果只考虑“绝对不报错/完美映射”, 最终给出的都是全篇纯白底色加单层 `` 的方正卡片网格, 极其死板单调, * + *这将被视为不及格!** +- **SVG 给你了完全的设计自由**, 请大胆使用你脑内的图标路径 (``), 连接指引 (`流畅的 `), 各种环境氛围点缀, + 大胆一点, 充分信任你的品味, 发挥出你的顶级艺术创造力! + +##### SVG 设计 Workflow + +###### 1. 想清楚要画什么 + +- **核心信息是什么?** 能做到一图胜千言, 绝对不要只生成平平无奇的文字表格, 要有设计感 +- **内容充实度**:如果用户描述稀疏简略, 利用你的领域知识扩展, 保证信息维度和内容充实, 但不要过度堆砌, 淹没重点 +- **视觉层级与隐喻**:这个没有固定的形式, 你自由判断, 比如: 给重要的节点加光环, 加高亮背景;给对比项设计天平或对称结构 + +###### 2. 写 SVG + +> [!IMPORTANT] +> 布局, 配色, 信息密度, 装饰物——**全部由你判断**, 打破单调的 `` 牢笼, 严禁通篇用矩形和文字应付用户 +> 操作边界约束: + +- **语言跟随用户**:图表文字的语言与用户 prompt 保持一致, 技术术语用行业里通用的写法, 不机械翻译 +- 文字用 ``(不是 ``), 容器宽度留够——画板按 CJK ≈ 1em / Latin ≈ 0.6em 重排 +- 连线使用正交折线替代斜直线(`` 带水平/垂直折点)视觉效果更好 +- 可自由使用 `translate`, `rotate`, `scale`但请尽量避免使用 `skewX` / `skewY` / `matrix(...)` 发生空间级扭曲 + +###### 画板怎么处理 SVG + +画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示);但 +`` / `` / `` 等装饰特性画板完全不支持,会导致渲染问题(见下方⚠️) +**不需要所有元素都可编辑, 但必须避免使用不支持的装饰特性, 且要兼顾可编辑和美观漂亮** + +**可识别的元素** + +- 形状:`` / `` / `` / `` +- 连线:`` / `` / ``(自动识别为直线 / 折线 / 曲线) +- 文本:`` / `` 画板硬编码 Noto Sans SC **文字必须用 ``** +- 分组:`` / `` / `` 引用 `` +- 变换:`translate` / `rotate` / `scale` 正常;`skewX` / `skewY` / `matrix(...)` 降级 + +> [!IMPORTANT] +> ⚠️ ** 不支持的装饰特性** + +- `` / `` / `` / `` / `` → 画板都不支持,**请避免使用,否则会导致画板渲染问题 + ** + +###### 3.插入后审查 + +插入画板后,可以从返回值使用 lark-cli 指令,将画板内容导出为 png +图片。若是对设计不满意,可以修改后,删除原来的画板再重新插入,或是调用 [ +`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。 + +```bash +lark-cli whiteboard +query \ + --whiteboard-token "wbcnxxxxxxxx" \ + --output_as image \ + --output ./preview.png +``` + +### 步骤 3B:编辑已有画板 — 启动 lark-whiteboard SubAgent 复杂图和已有画板更新必须启动 SubAgent。主 Agent 只传最小上下文,不直接执行 `lark-whiteboard` 的渲染和写入流程。 复杂图 SubAgent 的最小上下文: + - board_token - 图表目标、推荐画板类型、受众 - 与图表直接相关的源段落或数据 @@ -63,35 +140,12 @@ ### 步骤 4:完成校验 -- 简单 SVG:确认插入的是 ``,且内容是完整 `...` -- 复杂画板:确认每个 token 对应的画板都已填充真实内容 +- Mermaid: 确认插入的是 ``,且内容 mermaid 语法完整 +- SVG: 确认插入的是 ``,且内容是完整 `...` - 不保留空白占位画板;复杂路径只有空白画板而无内容视为任务未完成 --- -## 语义与画板类型映射 - -下表用于帮助主 Agent 判断简单/复杂路径,并给 SubAgent 指定推荐画板类型。 - -| 语义 | 画板类型 | -|------|------| -| 小型流程/状态机/简单时间线/小型对比/小型示意图 | SVG 画板(简单路径) | -| 架构/分层/技术方案/模块依赖/调用关系 | 架构图(复杂路径) | -| 流程/审批/部署/业务流转/状态机 | 流程图(按复杂度分流) | -| 跨角色流程/跨系统交互/端到端链路 | 泳道图(复杂路径) | -| 组织/层级/汇报关系 | 组织架构图 | -| 时间线/里程碑/版本规划 | 里程碑图 | -| 因果/复盘/根因分析 | 鱼骨图 | -| 方案对比/技术选型/功能矩阵 | 对比图 | -| 循环/飞轮/闭环/增长链路 | 飞轮图 | -| 层级占比/能力模型/需求层次 | 金字塔图 | -| 矩形树图/层级面积占比 | 树状图 | -| 转化漏斗/销售漏斗 | 漏斗图 | -| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid | -| 数据分布/占比/饼图 | Mermaid | -| 简单自定义图形/小型 SVG 示意图 | SVG 画板(简单路径) | -| 柱状图/条形图/数据对比 | 柱状图 | -| 折线图/趋势图/时序数据 | 折线图 | --- diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md index c4bda2416..464656714 100644 --- a/skills/lark-doc/references/style/lark-doc-create-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-create-workflow.md @@ -35,12 +35,12 @@ 5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果 6. 评估样式达标(富 block 密度、元素多样性、连续 `

` 数量) -7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、简单/复杂路径和用于画图的源内容 +7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容 ### 第四波 — 画板与润色(并行 Agent) + 8. **优先处理第三波识别出的画板需求**: - - 简单图:启动 SVG SubAgent,直接插入 `完整 SVG`;不读取 **lark-whiteboard** - - 复杂图:主 Agent 先插入 `` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板 + 参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。 9. Spawn 内容改写 Agent 定向润色: - 文字密集章节转为 ``/``/`` - 主要章节间补充 `
` @@ -51,6 +51,8 @@ 内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。 -SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `...`,不改其他正文,也不读取 `lark-whiteboard`。 +Mermaid 图由主 Agent 直接插入 `...`,无需 SubAgent。 -复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 +SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `...`,不改其他正文,也不读取 `lark-whiteboard`。 + +已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 diff --git a/skills/lark-doc/references/style/lark-doc-style.md b/skills/lark-doc/references/style/lark-doc-style.md index 0a27a173e..a940b5f95 100644 --- a/skills/lark-doc/references/style/lark-doc-style.md +++ b/skills/lark-doc/references/style/lark-doc-style.md @@ -12,21 +12,20 @@ ## 二、元素选择指南 -涉及图表需求时,先判定简单/复杂:简单图启动 SubAgent 直接插入 `完整 SVG`,不读取 **lark-whiteboard**;复杂图才使用空白画板 + **lark-whiteboard** SubAgent。 +涉及图表需求时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图用 `` 直接内嵌;其他新图表启动 SubAgent 插入 `完整 SVG`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。 + +| 场景 | 推荐方案 | +|--------------------------------------------|---------------------------------------| +| 核心结论 / 摘要 / 注意事项 | `` + emoji + 背景色 | +| 重要方案对比 / 优劣势 / Before vs After | `` 2 列分栏;SVG SubAgent | +| 简短低风险对比 | `` 2 列分栏 | +| 3+ 属性的结构化数据 / 指标表 | `
` + 表头背景色 | +| 任务清单 / 检查项 | `` | +| 代码片段 | `
`         |
+| 引用 / 公式                                    | `
` / `` | +| 操作入口 / 跳转链接 | `
` + 表头背景色 | -| 任务清单 / 检查项 | `` | -| 代码片段 | `
`                                 |
-| 引用 / 公式 | `
` / `` | -| 操作入口 / 跳转链接 | `
` / `` / `` 承载 - -### 画板语法与插入 - -> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需启动 SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。 - -#### 简单 SVG 画板(SubAgent 插入) - -1. 主 Agent 启动 SubAgent,传入 doc token、插入位置、图表目标和源内容 -2. SubAgent 使用 `完整自包含 SVG` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 -3. SVG 必须包含 `` 根节点和 `viewBox`,不要引用外部图片、脚本或远程资源 - -#### 复杂画板(空白画板 + lark-whiteboard SubAgent) - -1. 用 `` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入空白画板 -2. 从 v2 响应 `data.document.new_blocks` 中提取画板 `block_token` -3. 必须启动 SubAgent,把 `block_token`、图表目标、推荐画板类型和源内容交给它 -4. SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 并写入该画板;主 Agent 不直接调用画板渲染流程 - -更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。 +- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。 ## 三、颜色语义 diff --git a/skills/lark-doc/references/style/lark-doc-update-workflow.md b/skills/lark-doc/references/style/lark-doc-update-workflow.md index 20dd03fa9..bb4b2ced4 100644 --- a/skills/lark-doc/references/style/lark-doc-update-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-update-workflow.md @@ -25,14 +25,13 @@ - 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids` - 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope" 2. 系统性评估:结构清晰度、富 block 密度(≥40%)、元素多样性(≥3种)、连续 `

` 是否超过 3 段、是否有开头 callout 和章节 `


` -3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、简单/复杂路径和源内容片段 +3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段 4. 向用户简要说明改进计划(包含识别出的画板机会) ### 第二波 — 定向改写(并行 Agent) 5. **优先处理第一波识别出的画板候选段落**: - - 简单图:启动 SVG SubAgent,直接插入 `完整 SVG`;不读取 **lark-whiteboard** - - 复杂图:主 Agent 先插入 `` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板 + 参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。 6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`) - 开头适当添加 ``、重组引言 - 纯文本转为 ``/`
`/`` @@ -47,8 +46,10 @@ 内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。 -SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `...`,不改其他正文,也不读取 `lark-whiteboard`。 +Mermaid 图由主 Agent 直接插入 `...`,无需 SubAgent。 -复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 +SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `...`,不改其他正文,也不读取 `lark-whiteboard`。 + +已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 **上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。 From 1135fc2767ee86dcc8bddc6ba378bf1e0d0386fe Mon Sep 17 00:00:00 2001 From: SunPeiYang996 Date: Tue, 26 May 2026 21:55:42 +0800 Subject: [PATCH 11/62] fix: remove unsupported docs fetch text format (#1109) Change-Id: I1241ba6feede813c5bfec3e6820bc0886e39dc68 --- .gitignore | 2 +- shortcuts/doc/docs_fetch_v2.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 437052468..cf7fd1bec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Build output -/lark-cli +/lark-cli* .cache/ dist/ bin/ diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index f5a0df2c1..52885faed 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -16,7 +16,7 @@ import ( // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { return []common.Flag{ - {Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}}, + {Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}}, {Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"}, {Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}}, @@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} { return ro } -// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。 +// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。 func validateFetchDetail(runtime *common.RuntimeContext) error { format := strings.TrimSpace(runtime.Str("doc-format")) detail := strings.TrimSpace(runtime.Str("detail")) From e182b01f682e986caef47921909b9710f80b0aa6 Mon Sep 17 00:00:00 2001 From: caojie0621 Date: Tue, 26 May 2026 22:06:12 +0800 Subject: [PATCH 12/62] feat(drive): add secure label shortcuts (#985) --- shortcuts/drive/drive_secure_label.go | 124 +++++++++++++ shortcuts/drive/drive_secure_label_test.go | 164 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 2 + shortcuts/drive/shortcuts_test.go | 2 + skills/lark-drive/SKILL.md | 2 + .../references/lark-drive-secure-label.md | 52 ++++++ tests/cli_e2e/drive/coverage.md | 9 +- .../drive/drive_secure_label_dryrun_test.go | 98 +++++++++++ 8 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 shortcuts/drive/drive_secure_label.go create mode 100644 shortcuts/drive/drive_secure_label_test.go create mode 100644 skills/lark-drive/references/lark-drive-secure-label.md create mode 100644 tests/cli_e2e/drive/drive_secure_label_dryrun_test.go diff --git a/shortcuts/drive/drive_secure_label.go b/shortcuts/drive/drive_secure_label.go new file mode 100644 index 000000000..3d2dee1a6 --- /dev/null +++ b/shortcuts/drive/drive_secure_label.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + secureLabelReadScope = "drive:file.meta.sec_label.read_only" + secureLabelUpdateScope = "docs:secure_label:write_only" +) + +var secureLabelTypes = permApplyTypes + +// DriveSecureLabelList lists secure labels available to the current user. +var DriveSecureLabelList = common.Shortcut{ + Service: "drive", + Command: "+secure-label-list", + Description: "List secure labels available to the current user", + Risk: "read", + Scopes: []string{secureLabelReadScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"}, + {Name: "page-token", Desc: "pagination token from previous response"}, + {Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + pageSize := runtime.Int("page-size") + if pageSize < 1 || pageSize > 10 { + return output.ErrValidation("--page-size must be between 1 and 10") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List secure labels available to the current user"). + GET("/open-apis/drive/v2/my_secure_labels"). + Params(buildSecureLabelListParams(runtime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := runtime.CallAPI("GET", + "/open-apis/drive/v2/my_secure_labels", + buildSecureLabelListParams(runtime), + nil, + ) + if err != nil { + return err + } + runtime.OutFormat(data, nil, nil) + return nil + }, +} + +// DriveSecureLabelUpdate updates the secure label on a Drive file/document. +var DriveSecureLabelUpdate = common.Shortcut{ + Service: "drive", + Command: "+secure-label-update", + Description: "Update the secure label on a Drive file or document", + Risk: "write", + Scopes: []string{secureLabelUpdateScope}, + AuthTypes: []string{"user"}, + 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")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + 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")}). + Set("file_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return err + } + body := map[string]interface{}{"id": runtime.Str("label-id")} + data, err := runtime.CallAPI("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 + } + runtime.Out(data, nil) + return nil + }, +} + +func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + if pageToken := runtime.Str("page-token"); pageToken != "" { + params["page_token"] = pageToken + } + if lang := runtime.Str("lang"); lang != "" { + params["lang"] = lang + } + return params +} + +func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) { + return resolvePermApplyTarget(raw, explicitType) +} diff --git a/shortcuts/drive/drive_secure_label_test.go b/shortcuts/drive/drive_secure_label_test.go new file mode 100644 index 000000000..6132c4357 --- /dev/null +++ b/shortcuts/drive/drive_secure_label_test.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestDriveSecureLabelList_DryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--page-size", "5", + "--page-token", "page_1", + "--lang", "zh", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/drive/v2/my_secure_labels", + `"GET"`, + `"page_size": 5`, + `"page_token": "page_1"`, + `"lang": "zh"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--page-size", "11", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "page-size") { + t.Fatalf("expected page-size validation error, got: %v", err) + } +} + +func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/my_secure_labels?page_size=10", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "7217780879644737540", "name": "L1"}, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"L1"`) { + t.Fatalf("stdout missing label:\n%s", stdout.String()) + } +} + +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", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/drive/v2/files/doxTok123/secure_label", + `"PATCH"`, + `"docx"`, + `"id": "7217780879644737539"`, + `"file_token": "doxTok123"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "doxTok123", + "--type", "docx", + "--label-id", "7217780879644737539", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["id"] != "7217780879644737539" { + t.Fatalf("id = %v, want label id", body["id"]) + } +} + +func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(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: 403, + Body: map[string]interface{}{ + "code": 1063013, "msg": "Security label downgrade requires approval", + }, + }) + + targetURL := "https://example.feishu.cn/docx/doxTok123" + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", targetURL, + "--label-id", "7217780879644737539", + "--as", "user", + }, f, nil) + 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) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index dcf231e7c..91df7cc55 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut { DriveSync, DriveTaskResult, DriveApplyPermission, + DriveSecureLabelList, + DriveSecureLabelUpdate, DriveSearch, DriveInspect, } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 3707fc096..6f170ce3e 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+sync", "+task_result", "+apply-permission", + "+secure-label-list", + "+secure-label-update", "+search", "+inspect", } diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index cd83c9fa5..678749fac 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -283,6 +283,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | | [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document | | [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) | +| [`+secure-label-list`](references/lark-drive-secure-label.md) | List secure labels available to the current user | +| [`+secure-label-update`](references/lark-drive-secure-label.md) | Update a Drive file/document secure label; downgrade approval errors require opening the document UI | ## API Resources diff --git a/skills/lark-drive/references/lark-drive-secure-label.md b/skills/lark-drive/references/lark-drive-secure-label.md new file mode 100644 index 000000000..a8790d16c --- /dev/null +++ b/skills/lark-drive/references/lark-drive-secure-label.md @@ -0,0 +1,52 @@ +# drive +secure-label-list / +secure-label-update(云文档密级标签) + +## 何时使用 + +- `drive +secure-label-list`:查询当前用户可用的密级标签,先拿到目标 `id`。 +- `drive +secure-label-update`:把目标云文档调整为指定密级标签。 + +这两个 shortcut 都使用用户身份(`--as user`)。修改密级前,通常先执行 `+secure-label-list` 确认可用标签 ID。 + +## 查询可用密级标签 + +```bash +lark-cli drive +secure-label-list --page-size 10 --lang zh +``` + +可选参数: + +| 参数 | 说明 | +|------|------| +| `--page-size` | 分页大小,范围 `1..10`,默认 `10` | +| `--page-token` | 上一页响应里的 `page_token` | +| `--lang` | 标签语言:`zh`、`en`、`ja` | + +底层接口:`GET /open-apis/drive/v2/my_secure_labels`。 + +## 修改文档密级 + +```bash +lark-cli drive +secure-label-update \ + --token "https://example.feishu.cn/docx/doxcnxxxx" \ + --label-id "7217780879644737539" +``` + +参数: + +| 参数 | 说明 | +|------|------| +| `--token` | 目标文档 URL 或 bare token;URL 可自动推断 `--type` | +| `--type` | bare token 必填;URL 输入时可省略。可选:`doc`、`docx`、`sheet`、`file`、`bitable`、`mindnote`、`slides` | +| `--label-id` | 要设置的密级标签 ID | + +底层接口:`PATCH /open-apis/drive/v2/files/:file_token/secure_label`,query 参数 `type`,请求体 `{ "id": "" }`。 + +## 错误处理 + +CLI 不会在 shortcut 中为密级错误码追加专用 hint;agent 必须根据返回的 `error.code` 做以下引导。 + +| 错误码 | 含义 | 引导 | +|--------|------|------| +| `1063013` | 密级降级需要审批 | 提示用户打开目标文档,在文档界面完成密级降级审批后重试;如果用户传入的是文档 URL,必须把该 URL 一并给用户作为操作入口 | + +遇到 `1063013` 时,不要继续重试 API,也不要提示补 scope;这是文档侧审批流程要求,需要用户到文档里操作。 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 9cfc8f26c..1da102998 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -1,9 +1,9 @@ # Drive CLI E2E Coverage ## Metrics -- Denominator: 29 leaf commands -- Covered: 9 -- Coverage: 31.0% +- Denominator: 31 leaf commands +- Covered: 10 +- Coverage: 32.3% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. @@ -13,6 +13,7 @@ - TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API. - TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`. - TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`. +- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows. - TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs. - TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary. - TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary. @@ -34,6 +35,8 @@ | ✕ | drive +move | shortcut | | none | no move workflow yet | | ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | | ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status | +| ✓ | drive +secure-label-list | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--page-size`; `--page-token`; `--lang` | dry-run only; live label availability depends on tenant security-label configuration | +| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level | | ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure | | ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence | | ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet | diff --git a/tests/cli_e2e/drive/drive_secure_label_dryrun_test.go b/tests/cli_e2e/drive/drive_secure_label_dryrun_test.go new file mode 100644 index 000000000..2ebcba660 --- /dev/null +++ b/tests/cli_e2e/drive/drive_secure_label_dryrun_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_SecureLabelDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantMethod string + wantURL string + assert func(t *testing.T, out string) + }{ + { + name: "list available labels", + args: []string{ + "drive", "+secure-label-list", + "--page-size", "5", + "--page-token", "page_1", + "--lang", "zh", + "--dry-run", + }, + wantMethod: "GET", + wantURL: "/open-apis/drive/v2/my_secure_labels", + assert: func(t *testing.T, out string) { + if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 5 { + t.Fatalf("page_size = %d, want 5\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.page_token").String(); got != "page_1" { + t.Fatalf("page_token = %q, want page_1\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.lang").String(); got != "zh" { + t.Fatalf("lang = %q, want zh\nstdout:\n%s", got, out) + } + }, + }, + { + name: "update label with URL inference", + args: []string{ + "drive", "+secure-label-update", + "--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share", + "--label-id", "7217780879644737539", + "--dry-run", + }, + wantMethod: "PATCH", + wantURL: "/open-apis/drive/v2/files/doxcnE2E001/secure_label", + assert: func(t *testing.T, out string) { + if got := gjson.Get(out, "api.0.params.type").String(); got != "docx" { + t.Fatalf("type = %q, want docx\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.id").String(); got != "7217780879644737539" { + t.Fatalf("body.id = %q, want label id\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "file_token").String(); got != "doxcnE2E001" { + t.Fatalf("file_token = %q, want doxcnE2E001\nstdout:\n%s", got, out) + } + }, + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != tt.wantMethod { + t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + tt.assert(t, out) + }) + } +} From 367cfc9d06398a4ae14fe24fd75a4fccc1053fa0 Mon Sep 17 00:00:00 2001 From: hugang-lark Date: Tue, 26 May 2026 22:17:54 +0800 Subject: [PATCH 13/62] feat: support vc,note,minute event (#1113) --- events/minutes/minute_generated.go | 116 ++++++ events/minutes/minute_generated_test.go | 353 ++++++++++++++++++ events/minutes/preconsume.go | 33 ++ events/minutes/register.go | 42 +++ events/register.go | 6 +- events/vc/participant_meeting_ended.go | 77 ++++ events/vc/participant_meeting_ended_test.go | 203 ++++++++++ events/vc/preconsume.go | 33 ++ events/vc/register.go | 43 +++ events/vc/test_helpers_test.go | 30 ++ skills/lark-event/SKILL.md | 4 +- .../references/lark-event-minutes.md | 54 +++ skills/lark-event/references/lark-event-vc.md | 50 +++ 13 files changed, 1042 insertions(+), 2 deletions(-) create mode 100644 events/minutes/minute_generated.go create mode 100644 events/minutes/minute_generated_test.go create mode 100644 events/minutes/preconsume.go create mode 100644 events/minutes/register.go create mode 100644 events/vc/participant_meeting_ended.go create mode 100644 events/vc/participant_meeting_ended_test.go create mode 100644 events/vc/preconsume.go create mode 100644 events/vc/register.go create mode 100644 events/vc/test_helpers_test.go create mode 100644 skills/lark-event/references/lark-event-minutes.md create mode 100644 skills/lark-event/references/lark-event-vc.md diff --git a/events/minutes/minute_generated.go b/events/minutes/minute_generated.go new file mode 100644 index 000000000..f4e4ec9da --- /dev/null +++ b/events/minutes/minute_generated.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/validate" +) + +const ( + minutesDetailRetryDelay = 500 * time.Millisecond + minutesDetailMaxRetries = 2 +) + +// MinutesMinuteSourceOutput is the flattened minute source payload. +type MinutesMinuteSourceOutput struct { + SourceType string `json:"source_type,omitempty" desc:"Minute source type"` + SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"` +} + +// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1. +type MinutesMinuteGeneratedOutput struct { + Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"` + Title string `json:"title,omitempty" desc:"Minute title"` + MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"` +} + +func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + MinuteToken string `json:"minute_token"` + MinuteSource struct { + SourceType string `json:"source_type"` + SourceEntityID string `json:"source_entity_id"` + } `json:"minute_source"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + out := &MinutesMinuteGeneratedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + MinuteToken: envelope.Event.MinuteToken, + } + if out.Type == "" { + out.Type = raw.EventType + } + if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" { + out.MinuteSource = &MinutesMinuteSourceOutput{ + SourceType: src.SourceType, + SourceEntityID: src.SourceEntityID, + } + } + + if rt != nil && out.MinuteToken != "" { + fillMinutesMinuteGeneratedDetails(ctx, rt, out) + } + + return json.Marshal(out) +} + +func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) { + if rt == nil || out == nil || out.MinuteToken == "" { + return + } + + path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken)) + + type minuteDetailResp struct { + Data struct { + Minute struct { + Title string `json:"title"` + } `json:"minute"` + } `json:"data"` + } + + for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ { + if attempt > 0 { + time.Sleep(minutesDetailRetryDelay) + } + + raw, err := rt.CallAPI(ctx, "GET", path, nil) + if err != nil { + continue + } + + var resp minuteDetailResp + if err := json.Unmarshal(raw, &resp); err != nil { + continue + } + + if resp.Data.Minute.Title == "" { + continue + } + + out.Title = resp.Data.Minute.Title + return + } +} diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go new file mode 100644 index 000000000..9a0a5b13e --- /dev/null +++ b/events/minutes/minute_generated_test.go @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/validate" +) + +type stubAPIClient struct { + callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error) +} + +func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + if s.callFn == nil { + return nil, nil + } + return s.callFn(ctx, method, path, body) +} + +func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) { + t.Helper() + want := map[string]string{"event_type": wantEventType} + if !reflect.DeepEqual(gotBody, want) { + t.Fatalf("request body = %#v, want %#v", gotBody, want) + } +} + +func TestMain(m *testing.M) { + for _, k := range Keys() { + event.RegisterKey(k) + } + os.Exit(m.Run()) +} + +func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeMinuteGenerated) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } +} + +func TestProcessMinutesMinuteGenerated(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + var gotMethod, gotPath string + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + gotMethod = method + gotPath = path + if body != nil { + t.Fatalf("GET detail body = %#v, want nil", body) + } + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "token": "", + "title": "产品周会的视频会议", + "note_id": "7616590025794260496" + } + } + }`), nil + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_001", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "minute_token": "", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "6911188411934433028" + } + } + }`) + + if gotMethod != "GET" { + t.Errorf("detail method = %q, want GET", gotMethod) + } + if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("")) { + t.Errorf("detail path = %q", gotPath) + } + if out.Type != eventTypeMinuteGenerated { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" { + t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp) + } + if out.MinuteToken != "" { + t.Errorf("MinuteToken = %q", out.MinuteToken) + } + if out.Title != "产品周会的视频会议" { + t.Errorf("Title = %q", out.Title) + } + if out.MinuteSource == nil { + t.Fatal("MinuteSource should not be nil") + } + if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" { + t.Errorf("MinuteSource = %+v", out.MinuteSource) + } +} + +func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + called++ + return nil, context.DeadlineExceeded + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_002", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989001" + }, + "event": { + "minute_token": "", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "7641156270787481117" + } + } + }`) + + wantCalls := 1 + minutesDetailMaxRetries + if called != wantCalls { + t.Fatalf("detail API called %d times, want %d", called, wantCalls) + } + if out.MinuteToken != "" { + t.Errorf("MinuteToken = %q", out.MinuteToken) + } + if out.Title != "" { + t.Errorf("Title = %q, want empty", out.Title) + } + if out.MinuteSource == nil { + t.Fatal("MinuteSource should remain from event payload") + } + if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" { + t.Errorf("MinuteSource = %+v", out.MinuteSource) + } +} + +func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) { + called++ + if called <= 1 { + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "title": "" + } + } + }`), nil + } + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "title": "delayed title" + } + } + }`), nil + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_retry", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "minute_token": "" + } + }`) + + if called != 2 { + t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called) + } + if out.Title != "delayed title" { + t.Errorf("Title = %q, want delayed title", out.Title) + } +} + +func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) { + called++ + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "title": "" + } + } + }`), nil + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_exhaust", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "minute_token": "" + } + }`) + + wantCalls := 1 + minutesDetailMaxRetries + if called != wantCalls { + t.Fatalf("detail API called %d times, want %d", called, wantCalls) + } + if out.Title != "" { + t.Errorf("Title = %q, want empty after exhausted retries", out.Title) + } +} + +func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeMinuteGenerated) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated) +} + +func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + raw := &event.RawEvent{ + EventType: eventTypeMinuteGenerated, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } +} + +func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput { + t.Helper() + raw := &event.RawEvent{ + EventType: eventTypeMinuteGenerated, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + var out MinutesMinuteGeneratedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got)) + } + return out +} diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go new file mode 100644 index 000000000..82c329c85 --- /dev/null +++ b/events/minutes/preconsume.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" +) + +const cleanupTimeout = 5 * time.Second + +func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) { + return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) { + if rt == nil { + return nil, fmt.Errorf("runtime API client is required for pre-consume subscription") + } + + body := map[string]string{"event_type": eventType} + if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { + return nil, err + } + + return func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + }, nil + } +} diff --git a/events/minutes/register.go b/events/minutes/register.go new file mode 100644 index 000000000..bd0297bda --- /dev/null +++ b/events/minutes/register.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package minutes registers Minutes-domain EventKeys. +package minutes + +import ( + "reflect" + + "github.com/larksuite/cli/internal/event" +) + +const ( + eventTypeMinuteGenerated = "minutes.minute.generated_v1" + + pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription" + pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription" + + pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s" +) + +// Keys returns all Minutes-domain EventKey definitions. +func Keys() []event.KeyDefinition { + return []event.KeyDefinition{ + { + Key: eventTypeMinuteGenerated, + DisplayName: "Minute generated", + Description: "Triggered when a minute has been generated", + EventType: eventTypeMinuteGenerated, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})}, + }, + Process: processMinutesMinuteGenerated, + PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe), + Scopes: []string{"minutes:minutes.basic:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeMinuteGenerated}, + }, + } +} diff --git a/events/register.go b/events/register.go index 7ca984a0f..e570da623 100644 --- a/events/register.go +++ b/events/register.go @@ -6,13 +6,17 @@ package events import ( "github.com/larksuite/cli/events/im" + "github.com/larksuite/cli/events/minutes" + "github.com/larksuite/cli/events/vc" "github.com/larksuite/cli/internal/event" ) -// Mail is intentionally omitted: only IM is wired up this phase. +// Mail is intentionally omitted in this phase. func init() { all := [][]event.KeyDefinition{ im.Keys(), + minutes.Keys(), + vc.Keys(), } for _, keys := range all { for _, k := range keys { diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go new file mode 100644 index 000000000..4941b3b75 --- /dev/null +++ b/events/vc/participant_meeting_ended.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1. +type VCParticipantMeetingEndedOutput struct { + Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"` + Topic string `json:"topic,omitempty" desc:"Meeting topic"` + MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"` + StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"` + EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"` + CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` +} + +func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + meeting := envelope.Event.Meeting + out := &VCParticipantMeetingEndedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + MeetingID: meeting.ID, + Topic: meeting.Topic, + MeetingNo: meeting.MeetingNo, + StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), + EndTime: unixSecondsToLocalRFC3339(meeting.EndTime), + CalendarEventID: meeting.CalendarEventID, + } + if out.Type == "" { + out.Type = raw.EventType + } + return json.Marshal(out) +} + +func unixSecondsToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + secs, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.Unix(secs, 0).Local().Format(time.RFC3339) +} diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go new file mode 100644 index 000000000..0989f484c --- /dev/null +++ b/events/vc/participant_meeting_ended_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestMain(m *testing.M) { + for _, k := range Keys() { + event.RegisterKey(k) + } + os.Exit(m.Run()) +} + +func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeMeetingEnded) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } +} + +func TestProcessVCParticipantMeetingEnded(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_end_001", + "event_type": "vc.meeting.participant_meeting_ended_v1", + "create_time": "1608725989000", + "app_id": "cli_test" + }, + "event": { + "meeting": { + "id": "6911188411934433028", + "topic": "my meeting", + "meeting_no": "235812466", + "start_time": "1608883322", + "end_time": "1608883899", + "calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0" + } + } + }` + out := runMeetingEnded(t, payload) + + if out.Type != eventTypeMeetingEnded { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_vc_end_001" { + t.Errorf("EventID = %q", out.EventID) + } + if out.Timestamp != "1608725989000" { + t.Errorf("Timestamp = %q", out.Timestamp) + } + if out.MeetingID != "6911188411934433028" { + t.Errorf("MeetingID = %q", out.MeetingID) + } + if out.Topic != "my meeting" || out.MeetingNo != "235812466" { + t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo) + } + if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" { + t.Errorf("CalendarEventID = %q", out.CalendarEventID) + } + if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want { + t.Errorf("StartTime = %q, want %q", out.StartTime, want) + } + if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want { + t.Errorf("EndTime = %q, want %q", out.EndTime, want) + } +} + +func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_end_002", + "event_type": "vc.meeting.participant_meeting_ended_v1", + "create_time": "1608725989001" + }, + "event": { + "meeting": { + "id": "meeting_invalid_time", + "start_time": "bad", + "end_time": "" + } + } + }` + out := runMeetingEnded(t, payload) + if out.StartTime != "" || out.EndTime != "" { + t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime) + } +} + +func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + raw := &event.RawEvent{ + EventType: eventTypeMeetingEnded, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } +} + +func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1") + if !ok { + t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()") + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded) +} + +func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput { + t.Helper() + raw := &event.RawEvent{ + EventType: eventTypeMeetingEnded, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + var out VCParticipantMeetingEndedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got)) + } + return out +} diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go new file mode 100644 index 000000000..9bd03d941 --- /dev/null +++ b/events/vc/preconsume.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" +) + +const cleanupTimeout = 5 * time.Second + +func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) { + return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) { + if rt == nil { + return nil, fmt.Errorf("runtime API client is required for pre-consume subscription") + } + + body := map[string]string{"event_type": eventType} + if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { + return nil, err + } + + return func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + }, nil + } +} diff --git a/events/vc/register.go b/events/vc/register.go new file mode 100644 index 000000000..938f0aedc --- /dev/null +++ b/events/vc/register.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package vc registers VC-domain EventKeys. +package vc + +import ( + "reflect" + + "github.com/larksuite/cli/internal/event" +) + +const ( + eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" + eventTypeNoteGenerated = "vc.note.generated_v1" + + pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" + pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" + pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" + pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" +) + +// Keys returns all VC-domain EventKey definitions. +func Keys() []event.KeyDefinition { + return []event.KeyDefinition{ + { + Key: eventTypeMeetingEnded, + DisplayName: "Participant meeting ended", + Description: "Triggered when a meeting the current user participates in has ended", + EventType: eventTypeMeetingEnded, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})}, + }, + Process: processVCParticipantMeetingEnded, + PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe), + Scopes: []string{"vc:meeting.meetingevent:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeMeetingEnded}, + }, + } +} diff --git a/events/vc/test_helpers_test.go b/events/vc/test_helpers_test.go new file mode 100644 index 000000000..4d69d8e3a --- /dev/null +++ b/events/vc/test_helpers_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +type stubAPIClient struct { + callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error) +} + +func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + if s.callFn == nil { + return nil, nil + } + return s.callFn(ctx, method, path, body) +} + +func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) { + t.Helper() + want := map[string]string{"event_type": wantEventType} + if !reflect.DeepEqual(gotBody, want) { + t.Fatalf("request body = %#v, want %#v", gotBody, want) + } +} diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index c015fe837..3cb1bc5ba 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-event version: 1.0.0 -description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume ` (covers IM message receive, reactions, chat member changes, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses." +description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume ` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses." metadata: requires: bins: ["lark-cli"] @@ -143,3 +143,5 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val | Topic | Reference | Coverage | |---|---|---| | IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) | +| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 1 VC EventKey (`vc.meeting.participant_meeting_ended_v1`) + field reference + time conversion gotchas (unix seconds → local RFC3339) | +| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + enrichment & degradation semantics (minute detail API fills `title`; `minute_source` from event payload survives enrichment failure) | diff --git a/skills/lark-event/references/lark-event-minutes.md b/skills/lark-event/references/lark-event-minutes.md new file mode 100644 index 000000000..537a25a83 --- /dev/null +++ b/skills/lark-event/references/lark-event-minutes.md @@ -0,0 +1,54 @@ +# Minutes Events + +> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage). + +## Key catalog (1) + +| EventKey | Purpose | +|---|---| +| `minutes.minute.generated_v1` | A minute (妙记) has been generated | + +This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. + +## Scopes & auth + +| EventKey | Scope | Auth | +|---|---|---| +| `minutes.minute.generated_v1` | `minutes:minutes.basic:read` | user | + +Requires `--as user`. + +## `minutes.minute.generated_v1` + +### Output fields + +| Field | Type | Description | +|---|---|---| +| `type` | string | Event type; always `minutes.minute.generated_v1` | +| `event_id` | string | Globally unique event ID; safe for deduplication | +| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) | +| `minute_token` | string | Minute token | +| `title` | string | Minute title (enriched via detail API) | +| `minute_source` | object | Minute source metadata; only present when the source is a meeting | +| `minute_source.source_type` | string | Source type; only present when the source is a meeting (value: `meeting`) | +| `minute_source.source_entity_id` | string | Source entity ID (meeting ID); only present when the source is a meeting | + +### Enrichment & degradation + +The Process hook calls `GET /open-apis/minutes/v1/minutes/{minute_token}` to enrich `title`. If the detail API fails, this field is left empty — the base fields (`type`, `event_id`, `timestamp`, `minute_token`, `minute_source`) are always present. + +`minute_source` is populated from the event payload directly (not the detail API), so it survives enrichment failures. Note: `minute_source` is only present when the minute originates from a meeting; for other sources (e.g. recording, local upload) this field is absent. + +### Example + +```bash +lark-cli event consume minutes.minute.generated_v1 --as user + +# Project title and token only (skip events where enrichment failed) +lark-cli event consume minutes.minute.generated_v1 --as user \ + --jq 'select(.title != "") | {minute_token, title}' + +# Filter by source type +lark-cli event consume minutes.minute.generated_v1 --as user \ + --jq 'select(.minute_source.source_type == "meeting") | {minute_token, title}' +``` diff --git a/skills/lark-event/references/lark-event-vc.md b/skills/lark-event/references/lark-event-vc.md new file mode 100644 index 000000000..7dededd90 --- /dev/null +++ b/skills/lark-event/references/lark-event-vc.md @@ -0,0 +1,50 @@ +# VC Events + +> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage). + +## Key catalog (1) + +| EventKey | Purpose | +|---|---| +| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended | + +This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. + +## Scopes & auth + +| EventKey | Scope | Auth | +|---|---|---| +| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user | + +Requires `--as user`. + +## `vc.meeting.participant_meeting_ended_v1` + +### Output fields + +| Field | Type | Description | +|---|---|---| +| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` | +| `event_id` | string | Globally unique event ID; safe for deduplication | +| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) | +| `meeting_id` | string | Meeting ID | +| `topic` | string | Meeting topic | +| `meeting_no` | string | Meeting number | +| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone | +| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone | +| `calendar_event_id` | string | Calendar event ID associated with the meeting | + +### Gotchas + +- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty. +- No detail API call is made; all fields come from the event payload itself. + +### Example + +```bash +lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user + +# Project meeting topic and end time only +lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user \ + --jq '{meeting: .meeting_id, topic: .topic, ended: .end_time}' +``` From 9e2be14301d42d9fecd77151bf81ce5a459c8024 Mon Sep 17 00:00:00 2001 From: sang-neo03 Date: Wed, 27 May 2026 12:04:01 +0800 Subject: [PATCH 14/62] feat(schema): output json spec envelope for all API commands (#1048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(schema): add envelope types and ordered properties container * feat(schema): build meta_data.json key-order index for property ordering * feat(schema): implement convertProperty with file/enum/range/nested handling * feat(schema): build inputSchema with x-in / file binary / yes injection * feat(schema): build outputSchema wrapping responseBody * feat(schema): build _meta with scopes/risk/access_tokens normalization * feat(schema): scaffold affordance overlay loader (PR-1 stub) * feat(schema): wire up AssembleEnvelope main entry point * feat(schema): parse dotted and space-separated path arguments * feat(schema): batch envelope assembly with optional method filter * feat(schema): implement L1-L3 envelope lint (structure/type/cross-field) * feat(schema): measure L4 coverage and gate all envelopes through L1-L3 * feat(schema): add golden test harness with UPDATE_GOLDEN refresh * test(schema): seed 20 golden envelopes covering edge cases * feat(schema): output MCP envelope as default JSON, preserve pretty mode Rewrites cmd/schema/schema.go so the default --format json branch emits MCP-spec envelopes via schema.AssembleAll/AssembleService/AssembleEnvelope. The legacy --format pretty branch is preserved verbatim and still uses printServices / printResourceList / printMethodDetail. Args max raised from 1 to 8 so the path can be supplied either as a single dotted argument (im.reactions.list) or as space-separated segments (im reactions list); both forms route through schema.ParsePath and produce byte-identical output. The completeSchemaPath function is extended to drive tab-completion for both forms: legacy dotted prefix when len(args) == 0, and per-segment resource/method completion when args already contains earlier segments. BREAKING CHANGE: default JSON output shape changes from the raw meta_data structure to an MCP envelope array/object. Existing scripts parsing the old shape must either pin --format pretty or migrate to the new envelope fields (name, description, inputSchema, outputSchema, _meta). * test(schema): cover envelope JSON output, space-form path, yes injection Replaces TestSchemaCmd_NoArgs with two variants reflecting the new default shape: TestSchemaCmd_NoArgs_Pretty asserts the legacy "Available services" text appears only under --format pretty, and TestSchemaCmd_NoArgs_JSON_IsArray asserts the default JSON output parses as an envelope array with at least 180 entries. Adds six new tests: - TestSchemaCmd_JSONIsEnvelope: single-method output has name / description / inputSchema / outputSchema / _meta keys and envelope_version "1.0". - TestSchemaCmd_SpaceSeparatedPath_EqualsDotted: dotted and space forms produce identical output bytes for the same command path. - TestSchemaCmd_ServiceListIsArray: schema returns a JSON array whose every entry's name starts with " ". - TestSchemaCmd_HighRiskYesInjection: high-risk-write commands inject inputSchema.properties.yes. - TestSchemaCmd_NoYesForReadRisk: read-risk commands do not inject yes. - TestSchemaCmd_PrettyUnchanged_KeyTextPresent: --format pretty still surfaces the legacy section markers (Parameters:, Response:, Identity:, Scopes:, CLI:). * feat(schema): assemble envelope from embedded data only for stability * chore(schema): lint cleanup * fix(schema): preserve dotted resource segments in envelope name Nested resources whose meta_data key contains a dot (e.g. chat.members, user_mailbox.templates) were previously split on '.' and rejoined with spaces, producing envelope names like 'im chat members bots'. AI consumers doing name.split(' ') and feeding the result back as argv got 'lark-cli im chat members bots' which the CLI rejects — the actual invocation form is 'lark-cli im chat.members bots'. Pass the dotted resource key as a single argv segment so the envelope name 'im chat.members bots' round-trips through name.split(' ') back to the CLI. Mirror the same convention in the golden harness so its single-method assembly matches the live AssembleService walk. * fix(schema): align MCP envelope output with JSON Schema 2020-12 contract - coerce enum literals to typed JSON values (integer to int64, number to float64, boolean to bool) so type:"integer" fields no longer emit string enums; sort numeric/boolean enums while preserving meta_data order for string enums that carry semantic priority - translate non-standard meta_data type:"list" to JSON Schema type:"array" with items:{} fallback when element shape is absent (covers the two mail attachment_ids fields) - render inputSchema.required even when empty so consumers see a stable envelope shape ("[]" means no required fields, not "field is missing") - reject trailing path segments in both JSON and pretty modes so schema im.messages.delete.foo errors instead of silently returning the delete method - drop dead "list type" entry from lint_test isKnownDataInconsistency whitelist now that list values are translated upstream * fix(schema): address CodeRabbit findings and stabilize CI tests CI fix - Replace hard-coded absolute key-order assertions in TestKeyOrderIndex_* and TestBuildInputSchema_* with set-membership and propagation invariants; the upstream meta_data API does not guarantee stable JSON key order across fetches, so the old tests were flaky on CI by design. - Skip byte-level TestGoldenEnvelopes when CI=true; golden snapshots are a manual refresh artefact tied to a specific meta_data fetch, not a CI gate. - Add TestMain to isolate registry-backed tests from any host ~/.lark-cli cache (LARKSUITE_CLI_CONFIG_DIR + LARKSUITE_CLI_REMOTE_META=off) so the suite gives the same answer on every machine. CodeRabbit review actionables - EmbeddedServiceNames returns a defensive copy so callers cannot mutate the package-level slice and affect subsequent assembly determinism. - coerceEnumValue is now also applied to default literals: integer fields no longer ship default: "500" — they ship default: 500 (same idea as the earlier enum coercion fix). - options-branch string enums preserve meta_data source order, matching the enum-branch policy; only numeric/boolean enums get sorted. - validatePropertyTypes now validates the array element schema itself (type, nested items), not only items.properties — previously a primitive element with an invalid type (e.g. items.type="list") slipped past lint. - OrderedProps.MarshalJSON falls back to alphabetical key order when Map has entries but Order is empty, instead of silently emitting {}. Tests pass locally and with CI=true env (simulating GitHub Actions). * chore(schema): refresh golden envelopes after meta_data drift Re-generated with UPDATE_GOLDEN=1 against the current meta_data.json snapshot. The bulk of the diff is upstream noise (description wording, enum entries, field order) which the CI snapshot diff can no longer reasonably gate (see previous commit). Side-effects of the code fixes in the parent commit are also captured: - integer-typed defaults now emit numeric literals (e.g. page_size default 500, not "500") thanks to coerceEnumValue - mail.user_mailbox.templates.create _meta.risk corrects to "write" (assembler already emitted "write"; the old golden was stale) * fix(schema): address CodeRabbit round-3 review findings - TestMain: cleanup now runs reliably. os.Exit skips deferred functions, so the previous defer os.RemoveAll(dir) never executed. Replace defer with explicit cleanup, and fail fast if MkdirTemp errors instead of silently running against the host cache (which defeats isolation). - convertProperty default coercion: when the literal cannot be coerced to the declared type (e.g. default:"" on integer field, used by meta_data to mean "no default"), omit the field entirely rather than emit a type-mismatched default. Removes a contract violation flagged on im.reactions.list.json#page_size. * feat(schema): wire affordance overlay into envelope _meta Replace the loadAffordance stub (which always returned nil and read from an empty embedded annotations/ directory) with parseAffordance, which lifts the affordance block from method["affordance"]. The block is authored under larksuite-cli-registry's registry-config.yaml in the overrides: section and flows through gen-registry.py's deep_merge into the embedded meta_data.json. Simplify buildMeta signature: the service/resourcePath/method args existed only to feed the old dotted-path lookup. Refresh 9 golden envelopes for unrelated upstream meta_data.json drift. * refactor(schema): drop x-in extension from inputSchema x-in (path/query/body) was an HTTP-shape leak in a CLI-facing tool spec. AI consumers call the CLI by name with named args — they never construct HTTP requests directly, so the path-vs-body-vs-query distinction is the CLI's internal concern, not part of the contract. Execution path (cmd/service/service.go) already reads location from meta_data.json directly, so removing x-in does not affect routing. Drop: - Property.XIn field - validXIn map and the two lint rules that depend on x-in (L1 "top-level missing x-in" and L2 "path field must be in required") - contains() helper, no longer referenced after the path-required rule went away Refresh 20 goldens for the now-absent x-in lines. * refactor(schema): wrap inputSchema into params/data/flags sub-objects Replace the flat inputSchema with a 3-bucket nested structure that mirrors the CLI's actual flag layout, so AI consumers can directly map envelope fields to lark-cli invocation: inputSchema: properties: params: { ...path + query fields } → CLI --params JSON data: { ...body fields } → CLI --data JSON flags: { yes: ... } → CLI --yes (only for high-risk-write) Each sub-object only appears when the method has the corresponding source, so read-only GETs have a single `params` block, body-only POSTs have a single `data` block, etc. The `flags` wrapper carries an explicit description marking it as a CLI control bucket (not API fields), so AI does not confuse `yes` with a backend parameter. Lint: - L2 walkForL2 helper recurses into params/data sub-objects so leaf invariants (format:binary on non-string, min= yes boolean → --yes (only when risk == high-risk-write) Each slot is conditional: only registered when the method actually has fields for that source. This matches the CLI's own conditional flag registration (cmd/service/service.go:170-195), so what AI sees in the schema is exactly what flags exist for that method. The file sub-object carries a description explaining its semantics so AI knows to use --file for those fields rather than embedding the binary in --data JSON. Refresh im.images.create golden (the only file-upload method in the golden set). * test(schema): cover L2 lint recursion into params/data sub-objects Add two negative test cases that stuff bad values inside the wrapped inputSchema sub-objects (rather than at top-level), to lock in walkForL2's recursive coverage: - format:binary on a non-string field nested under params - sub-object Required referencing a key not in its Properties Regression guard so future walkForL2 refactors do not silently lose recursion and let leaf-field violations slip past lint. * fix(schema): coerce example, aggregate nested required, fix path hint - coerce `example` literal to the declared JSON Schema type (rename coerceEnumValue -> coerceLiteral, drop on coerce failure to match the `default` policy). Without this, integer/boolean/number fields emitted string examples and failed strict validators. - aggregate child field `required:true` into the enclosing nested object's `required[]` (both object and array-items shapes). Previously only the top-level params/data sub-objects scanned `required`, so envelopes silently under-reported the real call contract. - check method existence before reporting trailing-segment failure in both JSON and pretty `schema` paths. A typo like `schema im messages typo extra` now reports "Unknown method: im.messages.typo" instead of the misleading "Method 'typo' exists but trailing segments ..." hint. - extract risk level constants (RiskRead / RiskWrite / RiskHighRiskWrite) in internal/cmdutil/risk.go; replace literal usages in schema, lint, and confirm helpers so the typo radius is one file. - reconcile AssembleEnvelope docstring with implementation reality (the package-level currentMethodOrder + assembleMu serialize concurrent callers; output is deterministic per inputs). - drop testdata/golden/ and golden_test harness. End-to-end envelope shape regression now relies on real CLI invocations and the existing property-level unit + lint coverage. * fix(schema): emit items:{} for all typeless arrays, restore lint gate The list→array fallback only added items:{} when the source type was "list", leaving ~64 natively-typed array fields (e.g. approval.instances.cc.cc_user_ids) as {type:"array"} with no items. These violated the L1 lint rule, but TestAllEnvelopesPass skipped the "array missing items" error as a known data inconsistency, so the MCP tool contract was not actually lint-clean. Relax the fallback to cover every array lacking element shape regardless of source type, and drop the lint-test skip so the gate is hard again. --- cmd/schema/schema.go | 370 ++++++++++--- cmd/schema/schema_test.go | 159 +++++- internal/cmdutil/confirm.go | 2 +- internal/cmdutil/risk.go | 15 +- internal/registry/loader.go | 58 ++ internal/schema/assembler.go | 874 ++++++++++++++++++++++++++++++ internal/schema/assembler_test.go | 781 ++++++++++++++++++++++++++ internal/schema/lint.go | 233 ++++++++ internal/schema/lint_test.go | 379 +++++++++++++ internal/schema/path.go | 30 + internal/schema/path_test.go | 34 ++ internal/schema/types.go | 163 ++++++ internal/schema/types_test.go | 58 ++ 13 files changed, 3057 insertions(+), 99 deletions(-) create mode 100644 internal/schema/assembler.go create mode 100644 internal/schema/assembler_test.go create mode 100644 internal/schema/lint.go create mode 100644 internal/schema/lint_test.go create mode 100644 internal/schema/path.go create mode 100644 internal/schema/path_test.go create mode 100644 internal/schema/types.go create mode 100644 internal/schema/types_test.go diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index e4114c5bc..5276052e0 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/schema" "github.com/larksuite/cli/internal/util" "github.com/spf13/cobra" ) @@ -24,7 +25,8 @@ type SchemaOptions struct { Ctx context.Context // Positional args - Path string + Path string // first positional, when only one is given + ExtraArgs []string // 2nd+ positional args (space-separated form) // Flags Format string @@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co opts := &SchemaOptions{Factory: f} cmd := &cobra.Command{ - Use: "schema [path]", + Use: "schema [path | service resource method]", Short: "View API method parameters, types, and scopes", - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(8), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.Path = args[0] } + if len(args) > 1 { + opts.ExtraArgs = args[1:] + } opts.Ctx = cmd.Context() if runF != nil { return runF(opts) @@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp }) - cmdutil.SetRisk(cmd, "read") + cmdutil.SetRisk(cmd, cmdutil.RiskRead) return cmd } // completeSchemaPath provides tab-completion for the schema path argument. -// It handles dotted resource names (e.g. app.table.fields) by iterating all -// resources and classifying each as a prefix-match or fully-matched. +// It handles both legacy dotted resource names (e.g. app.table.fields) and the +// newer space-separated form (e.g. `schema im messages reply`). func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } + mode := f.ResolveStrictMode(cmd.Context()) - parts := strings.Split(toComplete, ".") - - // Level 1: complete service names - if len(parts) <= 1 { - var completions []string - for _, s := range registry.ListFromMetaProjects() { - if strings.HasPrefix(s, toComplete) { - completions = append(completions, s+".") + // Case 1: legacy "single dotted arg" path — no previous args yet + if len(args) == 0 { + parts := strings.Split(toComplete, ".") + if len(parts) <= 1 { + var completions []string + for _, s := range registry.ListFromMetaProjects() { + if strings.HasPrefix(s, toComplete) { + completions = append(completions, s+".") + } + } + return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + spec = filterSpecByStrictMode(spec, mode) + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + afterService := strings.Join(parts[1:], ".") + completions := completeSchemaPathForSpec(serviceName, resources, afterService) + allTrailingDot := len(completions) > 0 + for _, c := range completions { + if !strings.HasSuffix(c, ".") { + allTrailingDot = false + break } } - return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + directive := cobra.ShellCompDirectiveNoFileComp + if allTrailingDot { + directive |= cobra.ShellCompDirectiveNoSpace + } + return completions, directive } - serviceName := parts[0] + // Case 2: space-form, args already has segments + // Walk down service -> resource(s) -> method based on existing args + serviceName := args[0] spec := registry.LoadFromMeta(serviceName) if spec == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - mode := f.ResolveStrictMode(cmd.Context()) spec = filterSpecByStrictMode(spec, mode) resources, _ := spec["resources"].(map[string]interface{}) if resources == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - afterService := strings.Join(parts[1:], ".") - completions := completeSchemaPathForSpec(serviceName, resources, afterService) - - allTrailingDot := len(completions) > 0 - for _, c := range completions { - if !strings.HasSuffix(c, ".") { - allTrailingDot = false - break + // args[1:] are resource path segments (possibly partial); current + // toComplete is the next segment under cursor. + consumed := args[1:] + resource, _, remaining := findResourceByPath(resources, consumed) + if resource == nil { + // Suggest top-level resource names that match toComplete + var completions []string + for resName := range resources { + if strings.HasPrefix(resName, toComplete) { + completions = append(completions, resName) + } + } + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } + if len(remaining) > 0 { + // Already typed past the resource — suggest methods + methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + var completions []string + for mName := range methods { + if strings.HasPrefix(mName, toComplete) { + completions = append(completions, mName) + } + } + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } + // Resource matched exactly, suggest methods + methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + var completions []string + for mName := range methods { + if strings.HasPrefix(mName, toComplete) { + completions = append(completions, mName) } } - directive := cobra.ShellCompDirectiveNoFileComp - if allTrailingDot { - directive |= cobra.ShellCompDirectiveNoSpace - } - return completions, directive + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp } } @@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error { out := opts.Factory.IOStreams.Out mode := opts.Factory.ResolveStrictMode(opts.Ctx) - if opts.Path == "" { - printServices(out) - return nil + // args may have arrived as a single string (legacy single-arg path) or + // split into multiple — normalize to a single args slice. + var rawArgs []string + if opts.Path != "" { + rawArgs = []string{opts.Path} } - - parts := strings.Split(opts.Path, ".") - - serviceName := parts[0] - spec := registry.LoadFromMeta(serviceName) - if spec == nil { - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown service: %s", serviceName), - fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) - } - - if len(parts) == 1 { - if opts.Format == "pretty" { - printResourceList(out, spec, mode) + if len(opts.ExtraArgs) > 0 { + if opts.Path != "" { + rawArgs = append([]string{opts.Path}, opts.ExtraArgs...) } else { - output.PrintJson(out, filterSpecByStrictMode(spec, mode)) + rawArgs = append([]string(nil), opts.ExtraArgs...) } - return nil } + parts := schema.ParsePath(rawArgs) + if opts.Format == "pretty" { + return runPrettyMode(out, parts, mode) + } + return runJSONMode(out, parts, mode) +} + +// runJSONMode dispatches list/single envelope output based on parts. +// JSON mode uses embedded data only (bypasses remote overlay) so envelope +// output is deterministic across machines. +func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error { + filter := strictModeFilter(mode) + + switch len(parts) { + case 0: + envs := schema.AssembleAll(filter) + output.PrintJson(out, envs) + return nil + case 1: + spec := registry.EmbeddedSpec(parts[0]) + if spec == nil { + return errUnknownEmbeddedService(parts[0]) + } + envs := schema.AssembleService(parts[0], spec, filter) + output.PrintJson(out, envs) + return nil + default: + return runJSONForPath(out, parts, filter) + } +} + +// runJSONForPath handles len(parts) >= 2: try resource match first, fallback +// to single-method match. Uses embedded data only. +func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error { + serviceName := parts[0] + spec := registry.EmbeddedSpec(serviceName) + if spec == nil { + return errUnknownEmbeddedService(serviceName) + } resources, _ := spec["resources"].(map[string]interface{}) resource, resName, remaining := findResourceByPath(resources, parts[1:]) if resource == nil { - var resNames []string + var names []string for k := range resources { - resNames = append(resNames, k) + names = append(names, k) } + sort.Strings(names) return output.ErrWithHint(output.ExitValidation, "validation", fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), - fmt.Sprintf("Available: %s", strings.Join(resNames, ", "))) + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) } - if len(remaining) == 0 { - if opts.Format == "pretty" { - fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) - methods, _ := resource["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - for _, mName := range sortedKeys(methods) { - m, _ := methods[mName].(map[string]interface{}) - httpMethod := registry.GetStrFromMap(m, "httpMethod") - desc := registry.GetStrFromMap(m, "description") - fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) - } - fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) - } else { - // For JSON output, filter methods in a copy to avoid mutating the registry. - if mode.IsActive() { - filtered := make(map[string]interface{}) - for k, v := range resource { - filtered[k] = v - } - if methods, ok := resource["methods"].(map[string]interface{}); ok { - filtered["methods"] = filterMethodsByStrictMode(methods, mode) - } - output.PrintJson(out, filtered) - } else { - output.PrintJson(out, resource) - } - } + // Resource-scoped envelope array + envs := assembleResource(serviceName, resName, resource, filter) + output.PrintJson(out, envs) return nil } + methodName := remaining[0] + methods, _ := resource["methods"].(map[string]interface{}) + method, ok := methods[methodName].(map[string]interface{}) + if !ok { + var names []string + for k := range methods { + names = append(names, k) + } + sort.Strings(names) + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) + } + if len(remaining) > 1 { + // Method exists but caller appended extra segments — reject so they + // don't silently get this method's schema when they typo'd the path. + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown path: %s.%s.%s", + serviceName, resName, strings.Join(remaining, ".")), + fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve", + methodName, strings.Join(remaining[1:], "."))) + } + if filter != nil && !filter(method) { + // Method exists in spec but filtered out by strict mode + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName), + "Use --as user / --as bot to switch") + } + env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method) + output.PrintJson(out, env) + return nil +} +func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope { + methods, _ := resource["methods"].(map[string]interface{}) + resourcePath := []string{resName} + var envs []schema.Envelope + for methodName, raw := range methods { + method, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if filter != nil && !filter(method) { + continue + } + envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method)) + } + sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name }) + return envs +} + +// runPrettyMode preserves the existing legacy pretty rendering verbatim. +// All printServices/printResourceList/printMethodDetail calls stay unchanged. +func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error { + if len(parts) == 0 { + printServices(out) + return nil + } + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return errUnknownService(serviceName) + } + if len(parts) == 1 { + printResourceList(out, spec, mode) + return nil + } + resources, _ := spec["resources"].(map[string]interface{}) + resource, resName, remaining := findResourceByPath(resources, parts[1:]) + if resource == nil { + var names []string + for k := range resources { + names = append(names, k) + } + sort.Strings(names) + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) + } + if len(remaining) == 0 { + fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) + methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + for _, mName := range sortedKeys(methods) { + m, _ := methods[mName].(map[string]interface{}) + httpMethod := registry.GetStrFromMap(m, "httpMethod") + desc := registry.GetStrFromMap(m, "description") + fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) + } + fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) + return nil + } methodName := remaining[0] methods, _ := resource["methods"].(map[string]interface{}) methods = filterMethodsByStrictMode(methods, mode) method, ok := methods[methodName].(map[string]interface{}) if !ok { - var mNames []string + var names []string for k := range methods { - mNames = append(mNames, k) + names = append(names, k) } + sort.Strings(names) return output.ErrWithHint(output.ExitValidation, "validation", fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), - fmt.Sprintf("Available: %s", strings.Join(mNames, ", "))) + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) } - - if opts.Format == "pretty" { - printMethodDetail(out, spec, resName, methodName, method) - } else { - output.PrintJson(out, method) + if len(remaining) > 1 { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown path: %s.%s.%s", + serviceName, resName, strings.Join(remaining, ".")), + fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve", + methodName, strings.Join(remaining[1:], "."))) } + printMethodDetail(out, spec, resName, methodName, method) return nil } +// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns +// nil if strict mode is not active. +func strictModeFilter(mode core.StrictMode) schema.MethodFilter { + if !mode.IsActive() { + return nil + } + token := registry.IdentityToAccessToken(string(mode.ForcedIdentity())) + return func(method map[string]interface{}) bool { + tokens, _ := method["accessTokens"].([]interface{}) + if tokens == nil { + return true // permissive when meta_data lacks accessTokens + } + for _, t := range tokens { + if s, _ := t.(string); s == token { + return true + } + } + return false + } +} + +func errUnknownService(name string) error { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown service: %s", name), + fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) +} + +// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded +// services (no overlay) because JSON mode itself bypasses overlay; suggesting +// overlay-only services would mislead callers when those services subsequently +// fail to resolve in envelope output. +func errUnknownEmbeddedService(name string) error { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown service: %s", name), + fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", "))) +} + // filterSpecByStrictMode returns a shallow copy of spec with each resource's methods // filtered by strict mode. Returns the original spec when strict mode is off. func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} { diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index da4129302..cb9e51c8b 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -5,6 +5,7 @@ package schema import ( "bytes" + "encoding/json" "strings" "testing" @@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) { } } -func TestSchemaCmd_NoArgs(t *testing.T) { +func TestSchemaCmd_NoArgs_Pretty(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) cmd := NewCmdSchema(f, nil) - cmd.SetArgs([]string{}) - err := cmd.Execute() - if err != nil { + cmd.SetArgs([]string{"--format", "pretty"}) + if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "Available services") { - t.Error("expected service list output") + t.Error("expected service list in pretty mode") + } +} + +func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{}) // default --format json + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := strings.TrimSpace(stdout.String()) + if !strings.HasPrefix(out, "[") { + head := out + if len(head) > 80 { + head = head[:80] + } + t.Errorf("expected JSON array root, first 80 chars:\n%s", head) + } + var envs []map[string]interface{} + if err := json.Unmarshal([]byte(out), &envs); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(envs) < 193 { + t.Errorf("envelopes count = %d, want >= 193", len(envs)) + } +} + +func TestSchemaCmd_JSONIsEnvelope(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.images.create", "--format", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("not valid JSON: %v\n%s", err, stdout.String()) + } + if env["name"] != "im images create" { + t.Errorf("name = %v, want \"im images create\"", env["name"]) + } + for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} { + if _, ok := env[key]; !ok { + t.Errorf("missing top-level key: %s", key) + } + } + meta, _ := env["_meta"].(map[string]interface{}) + if meta["envelope_version"] != "1.0" { + t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"]) + } +} + +func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) { + f1, out1, _, _ := cmdutil.TestFactory(t, nil) + cmd1 := NewCmdSchema(f1, nil) + cmd1.SetArgs([]string{"im", "images", "create"}) + if err := cmd1.Execute(); err != nil { + t.Fatalf("space form failed: %v", err) + } + + f2, out2, _, _ := cmdutil.TestFactory(t, nil) + cmd2 := NewCmdSchema(f2, nil) + cmd2.SetArgs([]string{"im.images.create"}) + if err := cmd2.Execute(); err != nil { + t.Fatalf("dotted form failed: %v", err) + } + + if out1.String() != out2.String() { + t.Errorf("space and dotted forms produced different output") + } +} + +func TestSchemaCmd_ServiceListIsArray(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var envs []map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil { + t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String()) + } + if len(envs) == 0 { + t.Fatal("expected non-empty array for service im") + } + for _, e := range envs { + name, _ := e["name"].(string) + if !strings.HasPrefix(name, "im ") { + t.Errorf("envelope name %q does not start with \"im \"", name) + } + } +} + +func TestSchemaCmd_HighRiskYesInjection(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.messages.delete"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + is, _ := env["inputSchema"].(map[string]interface{}) + props, _ := is["properties"].(map[string]interface{}) + if _, ok := props["yes"]; !ok { + t.Errorf("inputSchema.properties.yes missing for high-risk-write command") + } +} + +func TestSchemaCmd_NoYesForReadRisk(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.reactions.list"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + is, _ := env["inputSchema"].(map[string]interface{}) + props, _ := is["properties"].(map[string]interface{}) + if _, ok := props["yes"]; ok { + t.Errorf("yes property should not appear for risk=read command") + } +} + +func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.images.create", "--format", "pretty"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + // Existing pretty rendering surfaces these markers — they must still appear + for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} { + if !strings.Contains(out, want) { + t.Errorf("pretty output missing marker %q", want) + } } } diff --git a/internal/cmdutil/confirm.go b/internal/cmdutil/confirm.go index 45031521b..b2c9cf575 100644 --- a/internal/cmdutil/confirm.go +++ b/internal/cmdutil/confirm.go @@ -34,7 +34,7 @@ func RequireConfirmation(action string) error { Message: fmt.Sprintf("%s requires confirmation", action), Hint: "add --yes to confirm", Risk: &output.RiskDetail{ - Level: "high-risk-write", + Level: RiskHighRiskWrite, Action: action, }, }, diff --git a/internal/cmdutil/risk.go b/internal/cmdutil/risk.go index 112e0ae5f..22fb092c3 100644 --- a/internal/cmdutil/risk.go +++ b/internal/cmdutil/risk.go @@ -7,11 +7,20 @@ import "github.com/spf13/cobra" const riskLevelAnnotationKey = "risk_level" +// Risk level constants — the three-tier convention used across the CLI. +// Use these in place of string literals so the typo radius is one place, +// not every call site. +const ( + RiskRead = "read" + RiskWrite = "write" + RiskHighRiskWrite = "high-risk-write" +) + // SetRisk stores a command's static risk level on cobra annotations so the // help renderer (cmd/root.go) can surface a Risk: line without importing -// shortcuts/common. Levels follow the three-tier convention: "read" | "write" -// | "high-risk-write". Framework-level confirmation gating only acts on -// "high-risk-write". +// shortcuts/common. Levels follow the three-tier convention: RiskRead | +// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only +// acts on RiskHighRiskWrite. func SetRisk(cmd *cobra.Command, level string) { if level == "" { return diff --git a/internal/registry/loader.go b/internal/registry/loader.go index a310326d6..93360c2da 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -22,6 +22,64 @@ var registryFS embed.FS // embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in. var embeddedMetaJSON []byte +// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers +// that need to parse key order or other JSON-level structure not exposed by +// LoadFromMeta (which loses map insertion order). +func EmbeddedMetaJSON() []byte { + return embeddedMetaJSON +} + +var ( + embeddedServicesMap map[string]map[string]interface{} // service name -> spec + embeddedServiceNames []string // sorted + embeddedParseOnce sync.Once +) + +// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map +// without touching mergedServices. Safe to call multiple times (sync.Once). +func parseEmbeddedServices() { + embeddedParseOnce.Do(func() { + embeddedServicesMap = make(map[string]map[string]interface{}) + if len(embeddedMetaJSON) == 0 { + return + } + var wrapper struct { + Services []map[string]interface{} `json:"services"` + } + if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil { + return + } + for _, svc := range wrapper.Services { + name, _ := svc["name"].(string) + if name == "" { + continue + } + embeddedServicesMap[name] = svc + } + embeddedServiceNames = make([]string, 0, len(embeddedServicesMap)) + for name := range embeddedServicesMap { + embeddedServiceNames = append(embeddedServiceNames, name) + } + sort.Strings(embeddedServiceNames) + }) +} + +// EmbeddedSpec returns the embedded spec for one service, or nil if unknown. +// Bypasses remote overlay — used for deterministic envelope output. +func EmbeddedSpec(serviceName string) map[string]interface{} { + parseEmbeddedServices() + return embeddedServicesMap[serviceName] +} + +// EmbeddedServiceNames returns sorted embedded service names (no overlay). +// Returns a defensive copy — callers must not mutate the package-level slice. +func EmbeddedServiceNames() []string { + parseEmbeddedServices() + out := make([]string, len(embeddedServiceNames)) + copy(out, embeddedServiceNames) + return out +} + var ( mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec mergedProjectList []string // sorted project names diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go new file mode 100644 index 000000000..59f014805 --- /dev/null +++ b/internal/schema/assembler.go @@ -0,0 +1,874 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "bytes" + "encoding/json" + "sort" + "strconv" + "sync" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/registry" +) + +// MethodKeyOrder records the natural meta_data.json key order for one method's +// parameters / requestBody / responseBody. Nested object key orders are stored +// under NestedKeys, keyed by dotted path from the method root +// (e.g. "responseBody.items.properties"). +type MethodKeyOrder struct { + Parameters []string + RequestBody []string + ResponseBody []string + NestedKeys map[string][]string +} + +var ( + keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order + keyOrderInitOnce sync.Once +) + +// lookupKeyOrder returns the key-order record for service.resourcePath.method, +// or nil if the method is not in the embedded data (e.g. remote-cached). +func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder { + keyOrderInitOnce.Do(buildKeyOrderIndex) + if keyOrderIndex == nil { + return nil + } + dotted := dottedPath(service, resourcePath, method) + return keyOrderIndex[dotted] +} + +func dottedPath(service string, resourcePath []string, method string) string { + var buf bytes.Buffer + buf.WriteString(service) + for _, r := range resourcePath { + buf.WriteByte('.') + buf.WriteString(r) + } + buf.WriteByte('.') + buf.WriteString(method) + return buf.String() +} + +// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init, +// walking services -> resources -> methods -> {parameters,requestBody,responseBody} +// and recording each map's key insertion order via json.Decoder.Token(). +func buildKeyOrderIndex() { + raw := registry.EmbeddedMetaJSON() + if len(raw) == 0 { + return + } + keyOrderIndex = make(map[string]*MethodKeyOrder) + + dec := json.NewDecoder(bytes.NewReader(raw)) + // Top-level: { "services": [...], "version": "..." } + if !expectDelim(dec, '{') { + return + } + for dec.More() { + key, _ := readKey(dec) + if key != "services" { + skipValue(dec) + continue + } + if !expectDelim(dec, '[') { + return + } + for dec.More() { + parseService(dec) + } + // closing ] + _, _ = dec.Token() + } +} + +// parseService consumes one service object inside services[]. +// meta_data.json may emit "resources" before "name", so we first capture both +// raw fields, then walk resources with the resolved service name. +func parseService(dec *json.Decoder) { + if !expectDelim(dec, '{') { + return + } + var serviceName string + var resourcesRaw json.RawMessage + for dec.More() { + key, _ := readKey(dec) + switch key { + case "name": + tok, _ := dec.Token() + if s, ok := tok.(string); ok { + serviceName = s + } + case "resources": + if err := dec.Decode(&resourcesRaw); err != nil { + skipValue(dec) + } + default: + skipValue(dec) + } + } + _, _ = dec.Token() // closing } + if serviceName != "" && len(resourcesRaw) > 0 { + subDec := json.NewDecoder(bytes.NewReader(resourcesRaw)) + parseResources(subDec, serviceName, nil) + } +} + +// parseResources walks a resources map (resName -> resource object). +// resourcePath is the accumulated path of parent resources (for nested resources). +func parseResources(dec *json.Decoder, service string, resourcePath []string) { + if !expectDelim(dec, '{') { + return + } + for dec.More() { + resName, _ := readKey(dec) + parseResourceObj(dec, service, append(resourcePath, resName)) + } + _, _ = dec.Token() +} + +// parseResourceObj consumes one resource value: { methods: {...}, ... } and may +// recurse into nested resources via "resources" key if present. +func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) { + if !expectDelim(dec, '{') { + return + } + for dec.More() { + key, _ := readKey(dec) + switch key { + case "methods": + parseMethods(dec, service, resourcePath) + case "resources": + parseResources(dec, service, resourcePath) + default: + skipValue(dec) + } + } + _, _ = dec.Token() +} + +// parseMethods consumes the methods map (methodName -> method object). +func parseMethods(dec *json.Decoder, service string, resourcePath []string) { + if !expectDelim(dec, '{') { + return + } + for dec.More() { + methodName, _ := readKey(dec) + mko := parseMethod(dec) + dotted := dottedPath(service, resourcePath, methodName) + keyOrderIndex[dotted] = mko + } + _, _ = dec.Token() +} + +// parseMethod consumes one method object and records key orders. +func parseMethod(dec *json.Decoder) *MethodKeyOrder { + mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)} + if !expectDelim(dec, '{') { + return mko + } + for dec.More() { + key, _ := readKey(dec) + switch key { + case "parameters": + mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys) + case "requestBody": + mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys) + case "responseBody": + mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys) + default: + skipValue(dec) + } + } + _, _ = dec.Token() + return mko +} + +// recordObjectKeysRecursive consumes an object and records the top-level key +// order. It also recurses into each child's "properties" submap, recording +// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys +// in order. +func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string { + if !expectDelim(dec, '{') { + return nil + } + var order []string + for dec.More() { + key, _ := readKey(dec) + order = append(order, key) + // Each child value is itself an object; we want its nested "properties" order if present. + consumeFieldRecursive(dec, prefix+"."+key, nestedKeys) + } + _, _ = dec.Token() + if prefix != "" && len(order) > 0 { + nestedKeys[prefix] = order + } + return order +} + +// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and, +// if it contains "properties": {...}, recursively records that submap's order. +func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) { + tok, err := dec.Token() + if err != nil { + return + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + // Not an object — skip the rest of the value + skipValueAfterToken(dec, tok) + return + } + for dec.More() { + fieldKey, _ := readKey(dec) + if fieldKey == "properties" { + recordObjectKeysRecursive(dec, path+".properties", nestedKeys) + } else { + skipValue(dec) + } + } + _, _ = dec.Token() +} + +// --- json.Decoder helpers --- + +func expectDelim(dec *json.Decoder, want json.Delim) bool { + tok, err := dec.Token() + if err != nil { + return false + } + delim, ok := tok.(json.Delim) + return ok && delim == want +} + +func readKey(dec *json.Decoder) (string, error) { + tok, err := dec.Token() + if err != nil { + return "", err + } + s, _ := tok.(string) + return s, nil +} + +// skipValue consumes the next complete value (scalar, object, or array). +func skipValue(dec *json.Decoder) { + tok, err := dec.Token() + if err != nil { + return + } + skipValueAfterToken(dec, tok) +} + +func skipValueAfterToken(dec *json.Decoder, tok json.Token) { + delim, ok := tok.(json.Delim) + if !ok { + return + } + // We started inside a container of type `delim` ({ or [) and must eat + // tokens until that container closes, tracking nested containers of any + // kind. depth counts how many open containers we are currently inside. + _ = delim + depth := 1 + for depth > 0 { + t, err := dec.Token() + if err != nil { + return + } + if d, ok := t.(json.Delim); ok { + switch d { + case '{', '[': + depth++ + case '}', ']': + depth-- + } + } + } +} + +// coerceLiteral converts a meta_data literal (default / enum / example) to +// the JSON Schema type declared by the field (integer/number/boolean/string). +// meta_data stores every literal as a string, so without coercion an +// `integer` field would emit string literals and fail any standard validator. +// Already-typed values pass through unchanged. Returns (value, true) on +// success, or (nil, false) when the literal cannot be coerced (caller should +// drop it). +func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) { + s, isStr := raw.(string) + if !isStr { + // Already typed (e.g. meta_data emitted a JSON number/bool directly). + return raw, true + } + switch fieldType { + case "integer": + if v, err := strconv.ParseInt(s, 10, 64); err == nil { + return v, true + } + return nil, false + case "number": + if v, err := strconv.ParseFloat(s, 64); err == nil { + return v, true + } + return nil, false + case "boolean": + switch s { + case "true": + return true, true + case "false": + return false, true + } + return nil, false + default: // "string", "" (nested objects), or unknown + return s, true + } +} + +// sortEnum sorts an enum slice in-place using a comparator appropriate for +// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather +// than the lexicographic [1, 10, 2]. +func sortEnum(fieldType string, vals []interface{}) { + sort.SliceStable(vals, func(i, j int) bool { + switch fieldType { + case "integer": + ai, _ := vals[i].(int64) + bi, _ := vals[j].(int64) + return ai < bi + case "number": + af, _ := vals[i].(float64) + bf, _ := vals[j].(float64) + return af < bf + case "boolean": + ab, _ := vals[i].(bool) + bb, _ := vals[j].(bool) + return !ab && bb // false < true + default: + as, _ := vals[i].(string) + bs, _ := vals[j].(string) + return as < bs + } + }) +} + +// convertProperty recursively converts one meta_data field map into a Property. +// nestedPath is the dotted lookup key into the current method's NestedKeys map +// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested +// lookup needed. +func convertProperty(field map[string]interface{}, nestedPath string) Property { + var p Property + + rawType, _ := field["type"].(string) + switch rawType { + case "file": + p.Type = "string" + p.Format = "binary" + case "list": + // meta_data uses non-standard "list" on a couple of fields; + // translate to JSON Schema "array" so validators accept it. + p.Type = "array" + default: + p.Type = rawType + } + + if s, ok := field["description"].(string); ok { + p.Description = s + } + if v, ok := field["default"]; ok { + // Coerce default literal to match the declared JSON Schema type so + // validators do not reject e.g. {type:"integer", default:"500"}. + // When coercion fails (e.g. default:"" on an integer field, which + // meta_data uses to mean "no default"), omit the field entirely + // instead of emitting a type-mismatched default — the result is a + // missing `default` key rather than a contract violation. + if coerced, ok := coerceLiteral(p.Type, v); ok { + p.Default = coerced + } + } + if v, ok := field["example"]; ok { + // meta_data stores examples as strings even when the field is integer/ + // boolean/number; coerce to the declared type so downstream validators + // accept the envelope. Drop on coerce failure (same policy as default). + if coerced, ok := coerceLiteral(p.Type, v); ok { + p.Example = coerced + } + } + + // min / max are stored as strings in meta_data; parse on best-effort. + if minStr, ok := field["min"].(string); ok && minStr != "" { + if v, err := strconv.ParseFloat(minStr, 64); err == nil { + p.Minimum = &v + } + } + if maxStr, ok := field["max"].(string); ok && maxStr != "" { + if v, err := strconv.ParseFloat(maxStr, 64); err == nil { + p.Maximum = &v + } + } + + // enum: prefer existing "enum" array; else extract from options[].value. + // Values are typed per p.Type so integer fields get integer enums, etc. + // (JSON Schema 2020-12 requires enum value types to match the declared + // type — meta_data stores everything as strings.) + if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 { + for _, e := range enumRaw { + if v, ok := coerceLiteral(p.Type, e); ok { + p.Enum = append(p.Enum, v) + } + } + // Numeric/boolean enums get sorted (no inherent meaning in meta_data + // order); string enums keep meta_data order, which sometimes carries + // semantic priority (e.g. image_type ["message","avatar"]). + if p.Type != "string" && p.Type != "" { + sortEnum(p.Type, p.Enum) + } + } else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 { + seen := make(map[string]bool) + for _, o := range optsRaw { + om, ok := o.(map[string]interface{}) + if !ok { + continue + } + raw, ok := om["value"].(string) + if !ok || seen[raw] { + continue + } + seen[raw] = true + if v, ok := coerceLiteral(p.Type, raw); ok { + p.Enum = append(p.Enum, v) + } + } + // Same policy as the `enum` branch: numeric/boolean enums get sorted + // (no semantic meaning in source order); string enums keep meta_data + // order, which may carry semantic priority. + if p.Type != "string" && p.Type != "" { + sortEnum(p.Type, p.Enum) + } + } + + // nested properties: recurse + if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 { + nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath) + if p.Type == "array" { + // meta_data quirk: array element schema is wrapped in "properties". + // Unfold into Items: { type: "object", properties: } + p.Items = &Property{ + Type: "object", + Properties: nested, + Required: nestedRequired, + } + // Property.Properties stays nil for arrays + } else { + if p.Type == "" { + p.Type = "object" // infer + } + p.Properties = nested + p.Required = nestedRequired + } + } + + // array items fallback: emit `items: {}` (any schema) for every array that + // meta_data does not describe an element shape for — whether it arrived as + // "list" or natively as "array". Without this, typeless arrays (e.g. arrays + // of bare ID strings) violate the L1 lint rule and are not JSON Schema valid + // for consumers that require `items`. + if p.Type == "array" && p.Items == nil { + p.Items = &Property{} + } + + return p +} + +// buildOrderedProps converts a map[string]interface{} of field specs into an +// OrderedProps plus the alphabetized list of child keys marked `required:true` +// in meta_data. Callers attach that list to the enclosing object's `required`, +// so nested objects faithfully report their call contract (top-level required +// is handled separately by buildInputSchema). +func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) { + op := &OrderedProps{Map: make(map[string]Property, len(raw))} + + var required []string + keys := orderedKeys(raw, nestedPath) + for _, k := range keys { + fieldRaw, _ := raw[k].(map[string]interface{}) + op.Order = append(op.Order, k) + op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties") + if req, _ := fieldRaw["required"].(bool); req { + required = append(required, k) + } + } + sort.Strings(required) + return op, required +} + +// currentMethodOrder is the per-method key-order context used by orderedKeys. +// It is set inside AssembleEnvelope (under assembleMu) and reset on return. +var currentMethodOrder *MethodKeyOrder + +// parseAffordance lifts the affordance overlay from a method's raw meta_data.json +// entry into a typed *Affordance. Returns nil when the field is absent, malformed, +// or carries no populated subfields. +// +// Affordance is authored in larksuite-cli-registry's registry-config.yaml under +// overrides...affordance and flows through gen-registry.py's +// deep_merge into the embedded meta_data.json. +func parseAffordance(raw interface{}) *Affordance { + if raw == nil { + return nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil + } + var a Affordance + if err := json.Unmarshal(b, &a); err != nil { + return nil + } + if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { + return nil + } + return &a +} + +// convertAccessTokens translates from_meta accessTokens (uses "tenant") into +// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically. +// Unknown tokens are dropped. Returns an empty slice for nil/empty input. +func convertAccessTokens(raw []interface{}) []string { + seen := make(map[string]bool) + for _, t := range raw { + s, ok := t.(string) + if !ok { + continue + } + switch s { + case "tenant": + seen["bot"] = true + case "user": + seen["user"] = true + } + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// buildMeta produces the _meta extension namespace. +func buildMeta(method map[string]interface{}) *Meta { + m := &Meta{ + EnvelopeVersion: "1.0", + RequiredScopes: []string{}, // never nil for stable JSON + } + + if scopesRaw, ok := method["scopes"].([]interface{}); ok { + for _, s := range scopesRaw { + if str, ok := s.(string); ok { + m.Scopes = append(m.Scopes, str) + } + } + } + if rsRaw, ok := method["requiredScopes"].([]interface{}); ok { + for _, s := range rsRaw { + if str, ok := s.(string); ok { + m.RequiredScopes = append(m.RequiredScopes, str) + } + } + } + + atRaw, _ := method["accessTokens"].([]interface{}) + m.AccessTokens = convertAccessTokens(atRaw) + + m.Danger, _ = method["danger"].(bool) + + if risk, _ := method["risk"].(string); risk != "" { + m.Risk = risk + } else { + m.Risk = cmdutil.RiskRead + } + + if docURL, _ := method["docUrl"].(string); docURL != "" { + m.DocURL = docURL + } + + m.Affordance = parseAffordance(method["affordance"]) + return m +} + +// buildInputSchema produces the inputSchema for one API method. +// +// Top-level shape: +// +// { type: object, +// required: [<"params" if any param required>, <"data" if any body required>], +// properties: { +// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters +// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody +// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write" +// } } +// +// The params / data wrapping mirrors the CLI's actual flag layout: +// path+query → --params JSON, body → --data JSON, file → --file. AI consumers +// can pluck inputSchema.properties.params and pass it verbatim to --params. +// +// Caller must set currentMethodOrder for property-order preservation. +func buildInputSchema(method map[string]interface{}) *InputSchema { + is := &InputSchema{ + Type: "object", + Required: []string{}, // never nil — stable envelope shape + Properties: &OrderedProps{Map: make(map[string]Property)}, + } + + // Build the "params" sub-object from method.parameters (path + query). + paramsRaw, _ := method["parameters"].(map[string]interface{}) + paramsProps := &OrderedProps{Map: make(map[string]Property)} + var paramsRequired []string + for _, k := range orderedKeys(paramsRaw, "parameters") { + field, _ := paramsRaw[k].(map[string]interface{}) + prop := convertProperty(field, "parameters."+k+".properties") + paramsProps.Order = append(paramsProps.Order, k) + paramsProps.Map[k] = prop + if req, _ := field["required"].(bool); req { + paramsRequired = append(paramsRequired, k) + } + } + if len(paramsProps.Order) > 0 { + sort.Strings(paramsRequired) + is.Properties.Order = append(is.Properties.Order, "params") + is.Properties.Map["params"] = Property{ + Type: "object", + Required: paramsRequired, + Properties: paramsProps, + } + if len(paramsRequired) > 0 { + is.Required = append(is.Required, "params") + } + } + + // Split method.requestBody into two buckets: + // - data: non-file body fields → corresponds to CLI --data JSON + // - file: type:file body fields → corresponds to CLI --file = + // File fields are kept *out* of `data` so the schema mirrors the actual + // CLI flag dispatch: --file owns one wire format (multipart upload), + // --data owns the rest (JSON body). + bodyRaw, _ := method["requestBody"].(map[string]interface{}) + dataProps := &OrderedProps{Map: make(map[string]Property)} + fileProps := &OrderedProps{Map: make(map[string]Property)} + var dataRequired []string + var fileRequired []string + for _, k := range orderedKeys(bodyRaw, "requestBody") { + field, _ := bodyRaw[k].(map[string]interface{}) + prop := convertProperty(field, "requestBody."+k+".properties") + isFile := false + if t, _ := field["type"].(string); t == "file" { + isFile = true + } + if isFile { + fileProps.Order = append(fileProps.Order, k) + fileProps.Map[k] = prop + if req, _ := field["required"].(bool); req { + fileRequired = append(fileRequired, k) + } + } else { + dataProps.Order = append(dataProps.Order, k) + dataProps.Map[k] = prop + if req, _ := field["required"].(bool); req { + dataRequired = append(dataRequired, k) + } + } + } + if len(dataProps.Order) > 0 { + sort.Strings(dataRequired) + is.Properties.Order = append(is.Properties.Order, "data") + is.Properties.Map["data"] = Property{ + Type: "object", + Required: dataRequired, + Properties: dataProps, + } + if len(dataRequired) > 0 { + is.Required = append(is.Required, "data") + } + } + if len(fileProps.Order) > 0 { + sort.Strings(fileRequired) + is.Properties.Order = append(is.Properties.Order, "file") + is.Properties.Map["file"] = Property{ + Type: "object", + Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", + Required: fileRequired, + Properties: fileProps, + } + if len(fileRequired) > 0 { + is.Required = append(is.Required, "file") + } + } + + // high-risk-write injects a top-level `yes` confirmation flag — sibling + // of params/data. It is a CLI gate (consumed by lark-cli, not sent to + // the backend), not an API field. + if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite { + is.Properties.Order = append(is.Properties.Order, "yes") + falseVal := false + is.Properties.Map["yes"] = Property{ + Type: "boolean", + Default: falseVal, + Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.", + } + // yes is intentionally NOT added to top-level Required; the gate is + // enforced semantically (yes==true) by the CLI, not structurally. + } + + sort.Strings(is.Required) // alphabetical + return is +} + +// buildOutputSchema produces the outputSchema for one API method. +func buildOutputSchema(method map[string]interface{}) *OutputSchema { + os := &OutputSchema{ + Type: "object", + Properties: &OrderedProps{Map: make(map[string]Property)}, + } + respRaw, _ := method["responseBody"].(map[string]interface{}) + for _, k := range orderedKeys(respRaw, "responseBody") { + field, _ := respRaw[k].(map[string]interface{}) + os.Properties.Order = append(os.Properties.Order, k) + os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties") + } + return os +} + +// assembleMu serializes AssembleEnvelope calls so that the package-level +// currentMethodOrder pointer is safe for concurrent callers. +var assembleMu sync.Mutex + +// AssembleEnvelope is the main entry point: takes a service / resource path / +// method name plus its meta_data spec, and produces a fully assembled MCP +// envelope. Output is fully determined by inputs (same arguments → same +// envelope), but assembly briefly publishes the per-method key-order context +// through the package-level currentMethodOrder so orderedKeys can reach it +// without threading it through every helper. assembleMu serializes that +// publish, which is why concurrent callers are still safe — they queue +// rather than run in parallel. +// +// If parallelism becomes a bottleneck, replace currentMethodOrder with an +// assembler struct or pass *MethodKeyOrder explicitly down the call chain. +func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope { + assembleMu.Lock() + defer assembleMu.Unlock() + currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName) + defer func() { currentMethodOrder = nil }() + + name := serviceName + for _, r := range resourcePath { + name += " " + r + } + name += " " + methodName + + desc, _ := method["description"].(string) + + return Envelope{ + Name: name, + Description: desc, + InputSchema: buildInputSchema(method), + OutputSchema: buildOutputSchema(method), + Meta: buildMeta(method), + } +} + +// MethodFilter is an optional predicate used by AssembleService and +// AssembleAll to filter methods (e.g. by access token for strict mode). +// Pass nil to include all methods. +type MethodFilter func(method map[string]interface{}) bool + +// AssembleService assembles all methods under one service into a sorted +// envelope slice (sorted by Envelope.Name ascending). +func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope { + if spec == nil { + return nil + } + resources, _ := spec["resources"].(map[string]interface{}) + var out []Envelope + walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) { + if filter != nil && !filter(method) { + return + } + out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method)) + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// AssembleAll assembles every embedded service into one big sorted slice. +// Uses embedded data only (bypasses remote overlay) so envelope output is +// deterministic across machines (CI vs dev vs different user brands). +func AssembleAll(filter MethodFilter) []Envelope { + var out []Envelope + for _, svc := range registry.EmbeddedServiceNames() { + spec := registry.EmbeddedSpec(svc) + out = append(out, AssembleService(svc, spec, filter)...) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// walkMethods recursively walks resources -> methods, calling visit for each +// terminal method. It supports nested resources via the optional "resources" +// key inside a resource value (matches meta_data.json structure). +func walkMethods(resources map[string]interface{}, parentPath []string, + visit func(resourcePath []string, methodName string, method map[string]interface{})) { + for resName, resRaw := range resources { + resMap, ok := resRaw.(map[string]interface{}) + if !ok { + continue + } + curPath := append(append([]string(nil), parentPath...), resName) + if methods, ok := resMap["methods"].(map[string]interface{}); ok { + for mName, mRaw := range methods { + if m, ok := mRaw.(map[string]interface{}); ok { + visit(curPath, mName, m) + } + } + } + if nested, ok := resMap["resources"].(map[string]interface{}); ok { + walkMethods(nested, curPath, visit) + } + } +} + +// orderedKeys returns the keys of raw in their meta_data natural order if +// the current per-method key-order context has them recorded; otherwise +// alphabetical fallback. +func orderedKeys(raw map[string]interface{}, nestedPath string) []string { + if currentMethodOrder != nil && nestedPath != "" { + if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok { + // Filter to keys that actually exist in raw (defensive) + out := make([]string, 0, len(order)) + seen := make(map[string]bool) + for _, k := range order { + if _, ok := raw[k]; ok { + out = append(out, k) + seen[k] = true + } + } + // Append any keys present in raw but missing from order (defensive), + // alphabetically for determinism. + var extra []string + for k := range raw { + if !seen[k] { + extra = append(extra, k) + } + } + sort.Strings(extra) + out = append(out, extra...) + return out + } + } + // Fallback: alphabetical + keys := make([]string, 0, len(raw)) + for k := range raw { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go new file mode 100644 index 000000000..a935dafb0 --- /dev/null +++ b/internal/schema/assembler_test.go @@ -0,0 +1,781 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/registry" +) + +// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so +// the suite gives the same answer on every machine. Without this, a stale +// local remote_meta.json could surface methods that aren't in the embedded +// snapshot (or alter their data) depending on the contributor's environment. +// +// Note: os.Exit skips deferred functions, so cleanup is done explicitly +// after m.Run before exiting. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "schema-test-cfg-*") + if err != nil { + // Surface the failure rather than silently running against the host + // cache — that defeats the whole purpose of this isolation. + println("schema test setup: MkdirTemp failed:", err.Error()) + os.Exit(2) + } + os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +func TestKeyOrderIndex_ImReactionsList(t *testing.T) { + // We only assert key-set membership, not absolute order — the upstream + // meta_data API does not guarantee a stable JSON key sequence across + // fetches, so hard-coding the order makes CI flaky. Order preservation + // from input to output is tested separately in TestBuildInputSchema_*. + order := lookupKeyOrder("im", []string{"reactions"}, "list") + if order == nil { + t.Fatal("expected key order for im.reactions.list, got nil") + } + wantParams := map[string]bool{ + "message_id": true, "reaction_type": true, "page_token": true, + "page_size": true, "user_id_type": true, + } + if got, want := len(order.Parameters), len(wantParams); got != want { + t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters) + } + for _, k := range order.Parameters { + if !wantParams[k] { + t.Errorf("unexpected parameter key %q", k) + } + } + // im.reactions.list 是 GET,没有 requestBody + if len(order.RequestBody) != 0 { + t.Errorf("expected empty RequestBody, got %v", order.RequestBody) + } +} + +func TestKeyOrderIndex_ImImagesCreate(t *testing.T) { + // Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList. + order := lookupKeyOrder("im", []string{"images"}, "create") + if order == nil { + t.Fatal("expected key order for im.images.create, got nil") + } + wantBody := map[string]bool{"image_type": true, "image": true} + if got, want := len(order.RequestBody), len(wantBody); got != want { + t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody) + } + for _, k := range order.RequestBody { + if !wantBody[k] { + t.Errorf("unexpected requestBody key %q", k) + } + } +} + +func TestKeyOrderIndex_UnknownPath(t *testing.T) { + // 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底 + order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar") + if order != nil { + t.Errorf("expected nil for unknown path, got %+v", order) + } +} + +func TestConvertProperty_BasicTypes(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + wantType string + }{ + {"string", map[string]interface{}{"type": "string"}, "string"}, + {"integer", map[string]interface{}{"type": "integer"}, "integer"}, + {"boolean", map[string]interface{}{"type": "boolean"}, "boolean"}, + {"number", map[string]interface{}{"type": "number"}, "number"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertProperty(tt.input, "") + if got.Type != tt.wantType { + t.Errorf("Type = %q, want %q", got.Type, tt.wantType) + } + }) + } +} + +func TestConvertProperty_FileBinary(t *testing.T) { + input := map[string]interface{}{"type": "file", "description": "upload"} + got := convertProperty(input, "") + if got.Type != "string" { + t.Errorf("Type = %q, want \"string\"", got.Type) + } + if got.Format != "binary" { + t.Errorf("Format = %q, want \"binary\"", got.Format) + } +} + +func TestConvertProperty_OptionsToEnum(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "options": []interface{}{ + map[string]interface{}{"value": "banana"}, + map[string]interface{}{"value": "apple"}, + map[string]interface{}{"value": "banana"}, // duplicate + }, + } + got := convertProperty(input, "") + // string enums preserve source order (deduped), matching the `enum` + // branch. Numeric/boolean enums would still be sorted by value. + want := []interface{}{"banana", "apple"} + if !reflect.DeepEqual(got.Enum, want) { + t.Errorf("Enum = %v, want %v", got.Enum, want) + } +} + +func TestConvertProperty_EnumPassThrough(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "enum": []interface{}{"x", "y"}, + } + got := convertProperty(input, "") + want := []interface{}{"x", "y"} // pass through, no sort + if !reflect.DeepEqual(got.Enum, want) { + t.Errorf("Enum = %v, want %v", got.Enum, want) + } +} + +func TestConvertProperty_EnumIntegerCoerce(t *testing.T) { + input := map[string]interface{}{ + "type": "integer", + "options": []interface{}{ + map[string]interface{}{"value": "10"}, + map[string]interface{}{"value": "1"}, + map[string]interface{}{"value": "2"}, + }, + } + got := convertProperty(input, "") + want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted + if !reflect.DeepEqual(got.Enum, want) { + t.Errorf("Enum = %v, want %v", got.Enum, want) + } +} + +func TestConvertProperty_ListTypeFallback(t *testing.T) { + input := map[string]interface{}{ + "type": "list", + "description": "ids", + } + got := convertProperty(input, "") + if got.Type != "array" { + t.Errorf("Type = %q, want %q", got.Type, "array") + } + if got.Items == nil { + t.Fatalf("Items = nil, want non-nil (any-schema fallback)") + } +} + +func TestConvertProperty_MinMaxParsing(t *testing.T) { + input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"} + got := convertProperty(input, "") + if got.Minimum == nil || *got.Minimum != 10.0 { + t.Errorf("Minimum = %v, want 10", got.Minimum) + } + if got.Maximum == nil || *got.Maximum != 50.0 { + t.Errorf("Maximum = %v, want 50", got.Maximum) + } +} + +func TestConvertProperty_MinMaxInvalid(t *testing.T) { + input := map[string]interface{}{"type": "integer", "min": "not_a_number"} + got := convertProperty(input, "") + if got.Minimum != nil { + t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum) + } +} + +func TestConvertProperty_ArrayWithProperties(t *testing.T) { + // meta_data quirk: array element schema is in "properties" not "items" + input := map[string]interface{}{ + "type": "array", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + "name": map[string]interface{}{"type": "string"}, + }, + } + got := convertProperty(input, "") + if got.Type != "array" { + t.Fatalf("Type = %q, want \"array\"", got.Type) + } + if got.Items == nil { + t.Fatal("Items is nil, want non-nil") + } + if got.Items.Type != "object" { + t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type) + } + if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 { + t.Errorf("Items.Properties did not contain both id and name") + } + if got.Properties != nil { + t.Error("array Property must not have top-level Properties after unfold") + } +} + +func TestConvertProperty_ObjectWithProperties(t *testing.T) { + input := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "x": map[string]interface{}{"type": "string"}, + }, + } + got := convertProperty(input, "") + if got.Type != "object" { + t.Errorf("Type = %q, want \"object\"", got.Type) + } + if got.Properties == nil || got.Properties.Map["x"].Type != "string" { + t.Errorf("nested Properties not preserved") + } +} + +func TestConvertProperty_InferObjectFromProperties(t *testing.T) { + input := map[string]interface{}{ + "properties": map[string]interface{}{ + "y": map[string]interface{}{"type": "string"}, + }, + } + got := convertProperty(input, "") + if got.Type != "object" { + t.Errorf("Type = %q, want \"object\" (inferred)", got.Type) + } +} + +func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "ref": "operator", + "annotations": []interface{}{"readOnly"}, + "enumName": "FooEnum", + } + got := convertProperty(input, "") + // 这些字段直接被丢弃;Property 结构里也没存这些字段,断言只有 type 设置即可 + if got.Type != "string" { + t.Errorf("Type = %q", got.Type) + } +} + +func TestConvertProperty_DescriptionDefaultExample(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "description": "hello\nworld", + "default": "", + "example": "ex", + } + got := convertProperty(input, "") + if got.Description != "hello\nworld" { + t.Errorf("Description not preserved verbatim") + } + if got.Default != "" { + t.Errorf("Default = %v, want empty string (preserved)", got.Default) + } + if got.Example != "ex" { + t.Errorf("Example = %v, want \"ex\"", got.Example) + } +} + +func TestBuildInputSchema_ReactionsList(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + mko := lookupKeyOrder("im", []string{"reactions"}, "list") + currentMethodOrder = mko + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + + if is.Type != "object" { + t.Errorf("Type = %q, want \"object\"", is.Type) + } + // top-level required: ["params"] because message_id is a required path param + if !reflect.DeepEqual(is.Required, []string{"params"}) { + t.Errorf("Required = %v, want [params]", is.Required) + } + // top-level properties only contains "params" (no body fields, no high-risk-write) + if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) { + t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order) + } + // params sub-object: required + property order + params := is.Properties.Map["params"] + if params.Type != "object" { + t.Errorf("params.Type = %q, want \"object\"", params.Type) + } + if !reflect.DeepEqual(params.Required, []string{"message_id"}) { + t.Errorf("params.Required = %v, want [message_id]", params.Required) + } + if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) { + t.Errorf("params.properties order = %v, want (from key index) %v", + params.Properties.Order, mko.Parameters) + } +} + +func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"images"}, "create") + currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create") + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + + // top-level required: ["data", "file"] — image_type body required + image file required + if !reflect.DeepEqual(is.Required, []string{"data", "file"}) { + t.Errorf("Required = %v, want [data, file]", is.Required) + } + // top-level properties: data (for non-file body) + file (for binary upload) + if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) { + t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order) + } + // data sub-object carries only non-file body fields (image_type) + data := is.Properties.Map["data"] + if !reflect.DeepEqual(data.Required, []string{"image_type"}) { + t.Errorf("data.Required = %v, want [image_type]", data.Required) + } + if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) { + t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order) + } + if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) { + t.Errorf("image_type unexpected: %+v", it) + } + if _, isFile := data.Properties.Map["image"]; isFile { + t.Errorf("image (file field) should NOT appear in data sub-object") + } + + // file sub-object carries the binary upload field + file := is.Properties.Map["file"] + if file.Type != "object" { + t.Errorf("file.Type = %q, want \"object\"", file.Type) + } + if !reflect.DeepEqual(file.Required, []string{"image"}) { + t.Errorf("file.Required = %v, want [image]", file.Required) + } + if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) { + t.Errorf("file.properties order = %v, want [image]", file.Properties.Order) + } + img := file.Properties.Map["image"] + if img.Type != "string" { + t.Errorf("image.Type = %q, want \"string\"", img.Type) + } + if img.Format != "binary" { + t.Errorf("image.Format = %q, want \"binary\"", img.Format) + } +} + +func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) { + // Synthesized method to avoid registry-overlay variance (remote cache may + // strip `risk` field); buildInputSchema only cares about the method map. + method := map[string]interface{}{ + "risk": "high-risk-write", + "parameters": map[string]interface{}{ + "message_id": map[string]interface{}{ + "type": "string", + "location": "path", + "required": true, + }, + }, + } + currentMethodOrder = nil + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + + // yes lives at inputSchema.properties.yes (sibling of params/data) + yes, ok := is.Properties.Map["yes"] + if !ok { + t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found") + } + if yes.Type != "boolean" { + t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type) + } + if v, _ := yes.Default.(bool); v != false { + t.Errorf("yes.Default = %v, want false", yes.Default) + } + // yes must NOT be in top-level required + for _, r := range is.Required { + if r == "yes" { + t.Errorf("`yes` should not appear in top-level required") + } + } + // yes is appended to properties.Order + last := is.Properties.Order[len(is.Properties.Order)-1] + if last != "yes" { + t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order) + } +} + +func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + mko := lookupKeyOrder("im", []string{"reactions"}, "list") + currentMethodOrder = mko + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + if _, ok := is.Properties.Map["yes"]; ok { + t.Errorf("`yes` must not be injected for risk=read") + } +} + +func TestBuildOutputSchema_ReactionsList(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + mko := lookupKeyOrder("im", []string{"reactions"}, "list") + currentMethodOrder = mko + defer func() { currentMethodOrder = nil }() + + os := buildOutputSchema(method) + + if os.Type != "object" { + t.Errorf("Type = %q, want \"object\"", os.Type) + } + // Top-level response: has_more, page_token, items + if _, ok := os.Properties.Map["items"]; !ok { + t.Fatal("items not found in outputSchema") + } + items := os.Properties.Map["items"] + if items.Type != "array" { + t.Errorf("items.Type = %q, want \"array\"", items.Type) + } + if items.Items == nil { + t.Fatal("items.Items is nil (array unfold failed)") + } + if items.Items.Type != "object" { + t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type) + } +} + +func TestConvertAccessTokens(t *testing.T) { + tests := []struct { + name string + input []interface{} + want []string + }{ + {"tenant only", []interface{}{"tenant"}, []string{"bot"}}, + {"user only", []interface{}{"user"}, []string{"user"}}, + {"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}}, + {"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}}, + {"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}}, + {"empty", []interface{}{}, []string{}}, + {"nil", nil, []string{}}, + {"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertAccessTokens(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildMeta_FullFields(t *testing.T) { + // Synthesized method to avoid runtime variance from remote-cache overlay + // (which strips `risk` from merged services). All other field semantics + // match the real im.images.create entry in meta_data.json. + method := map[string]interface{}{ + "risk": "write", + "danger": true, + "scopes": []interface{}{ + "im:resource:upload", + "im:resource", + }, + "accessTokens": []interface{}{"tenant"}, + "docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create", + } + m := buildMeta(method) + + if m.EnvelopeVersion != "1.0" { + t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion) + } + if m.Risk != "write" { + t.Errorf("Risk = %q, want \"write\"", m.Risk) + } + if !m.Danger { + t.Errorf("Danger = false, want true") + } + if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) { + t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens) + } + if m.DocURL == "" { + t.Errorf("DocURL should be present for im.images.create") + } + if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) { + t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes) + } + if m.RequiredScopes == nil { + t.Errorf("RequiredScopes should be empty slice, not nil") + } + if len(m.RequiredScopes) != 0 { + t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes) + } + if m.Affordance != nil { + t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance) + } +} + +func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) { + method := map[string]interface{}{ + "scopes": []interface{}{"x"}, + "accessTokens": []interface{}{"user"}, + // no risk field + } + m := buildMeta(method) + if m.Risk != "read" { + t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk) + } +} + +func TestBuildMeta_RequiredScopesPresent(t *testing.T) { + method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get") + m := buildMeta(method) + if len(m.RequiredScopes) == 0 { + t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get") + } +} + +func TestParseAffordance_NilOrEmpty(t *testing.T) { + cases := []struct { + name string + raw interface{} + }{ + {"nil", nil}, + {"empty object", map[string]interface{}{}}, + {"all-five-empty-arrays", map[string]interface{}{ + "use_when": []interface{}{}, + "do_not_use_when": []interface{}{}, + "prerequisites": []interface{}{}, + "examples": []interface{}{}, + "related": []interface{}{}, + }}, + {"malformed (string)", "not an object"}, + {"malformed (number)", 42}, + {"malformed (nested type mismatch)", map[string]interface{}{ + "examples": "should be a list, not a string", + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := parseAffordance(c.raw); got != nil { + t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got) + } + }) + } +} + +func TestParseAffordance_FullPopulated(t *testing.T) { + raw := map[string]interface{}{ + "use_when": []interface{}{"需要拿到当前用户的主日历 ID"}, + "do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"}, + "prerequisites": []interface{}{"user 身份登录"}, + "examples": []interface{}{ + map[string]interface{}{"title": "获取主日历", "input": map[string]interface{}{}}, + }, + "related": []interface{}{"calendars.list"}, + } + a := parseAffordance(raw) + if a == nil { + t.Fatal("parseAffordance returned nil, want populated") + } + if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { + t.Errorf("UseWhen = %v", a.UseWhen) + } + if len(a.Examples) != 1 || a.Examples[0].Title != "获取主日历" { + t.Errorf("Examples = %+v", a.Examples) + } + if len(a.Related) != 1 || a.Related[0] != "calendars.list" { + t.Errorf("Related = %v", a.Related) + } +} + +func TestBuildMeta_AffordanceFromMethod(t *testing.T) { + method := map[string]interface{}{ + "scopes": []interface{}{"x"}, + "accessTokens": []interface{}{"user"}, + "risk": "read", + "affordance": map[string]interface{}{ + "use_when": []interface{}{"trigger"}, + }, + } + m := buildMeta(method) + if m.Affordance == nil { + t.Fatal("Affordance should be populated from method[\"affordance\"]") + } + if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" { + t.Errorf("UseWhen = %v", m.Affordance.UseWhen) + } +} + +func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { + method := map[string]interface{}{ + "scopes": []interface{}{"x"}, + "accessTokens": []interface{}{"user"}, + "risk": "read", + // no docUrl + } + m := buildMeta(method) + if m.DocURL != "" { + t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL) + } + // Verify JSON serialization omits doc_url + b, _ := json.Marshal(m) + if strings.Contains(string(b), "doc_url") { + t.Errorf("doc_url should be omitted from JSON, got: %s", b) + } +} + +func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) { + // 装配器对空 responseBody 应生成 properties = {} (不 nil) + method := map[string]interface{}{} + currentMethodOrder = nil + os := buildOutputSchema(method) + if os.Type != "object" { + t.Errorf("Type = %q, want \"object\"", os.Type) + } + if os.Properties == nil { + t.Fatal("Properties is nil, want empty OrderedProps") + } + if len(os.Properties.Order) != 0 { + t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order) + } +} + +func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + env := AssembleEnvelope("im", []string{"reactions"}, "list", method) + + if env.Name != "im reactions list" { + t.Errorf("Name = %q, want \"im reactions list\"", env.Name) + } + if env.Description == "" { + t.Errorf("Description should not be empty for im.reactions.list") + } + if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil { + t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil") + } + if env.Meta.EnvelopeVersion != "1.0" { + t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion) + } +} + +func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) { + // im.chat.members.create — resource path is one element "chat.members" with + // an internal dot. Substituted from plan's `bots` because remote-cache + // overlay strips `bots` from the loaded method map on this environment; + // the assertion is about name joining, not method specifics. + method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create") + env := AssembleEnvelope("im", []string{"chat.members"}, "create", method) + // chat.members resourcePath stays as one element in the slice with a dot; + // name should split it to "im chat.members create" — we keep the dot as-is + // inside the resource segment to round-trip with completion logic. + if env.Name != "im chat.members create" { + t.Errorf("Name = %q, want \"im chat.members create\"", env.Name) + } +} + +func TestAssembleEnvelope_JSONIsStable(t *testing.T) { + // Assemble twice; JSON output must be byte-identical (determinism). + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + a := AssembleEnvelope("im", []string{"reactions"}, "list", method) + b := AssembleEnvelope("im", []string{"reactions"}, "list", method) + ja, _ := json.MarshalIndent(a, "", " ") + jb, _ := json.MarshalIndent(b, "", " ") + if string(ja) != string(jb) { + t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb) + } +} + +func TestAssembleService_Im(t *testing.T) { + spec := registry.LoadFromMeta("im") + envs := AssembleService("im", spec, nil) + if len(envs) == 0 { + t.Fatal("expected non-empty envelopes for service im") + } + // Every envelope.Name starts with "im " + for _, e := range envs { + if !strings.HasPrefix(e.Name, "im ") { + t.Errorf("envelope name %q does not start with \"im \"", e.Name) + } + } + // Sorted by name + for i := 1; i < len(envs); i++ { + if envs[i-1].Name > envs[i].Name { + t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name) + } + } +} + +func TestAssembleService_FilterByAccessToken(t *testing.T) { + spec := registry.LoadFromMeta("im") + // Filter to bot-only (--as bot, which corresponds to "tenant") + envs := AssembleService("im", spec, func(method map[string]interface{}) bool { + tokens, _ := method["accessTokens"].([]interface{}) + for _, t := range tokens { + if s, _ := t.(string); s == "tenant" { + return true + } + } + return false + }) + // Every envelope's _meta.access_tokens must contain "bot" + for _, e := range envs { + found := false + for _, t := range e.Meta.AccessTokens { + if t == "bot" { + found = true + break + } + } + if !found { + t.Errorf("envelope %q does not declare bot access", e.Name) + } + } +} + +func TestAssembleAll_AtLeast193(t *testing.T) { + envs := AssembleAll(nil) + // Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the + // embedded meta_data.json directly, so the count is stable across machines. + if len(envs) < 193 { + t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs)) + } + // Spot check: im reactions list should be present + found := false + for _, e := range envs { + if e.Name == "im reactions list" { + found = true + break + } + } + if !found { + t.Errorf("im reactions list not found in AssembleAll output") + } +} + +// loadMethodFromRegistry is a test helper that pulls one method's spec from the +// real embedded meta_data.json via the registry package. +func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} { + t.Helper() + spec := registry.LoadFromMeta(service) + if spec == nil { + t.Fatalf("service %q not found in registry", service) + } + resources, _ := spec["resources"].(map[string]interface{}) + resKey := strings.Join(resourcePath, ".") + res, ok := resources[resKey].(map[string]interface{}) + if !ok { + t.Fatalf("resource %q.%s not found", service, resKey) + } + methods, _ := res["methods"].(map[string]interface{}) + m, ok := methods[methodName].(map[string]interface{}) + if !ok { + t.Fatalf("method %q.%s.%s not found", service, resKey, methodName) + } + return m +} diff --git a/internal/schema/lint.go b/internal/schema/lint.go new file mode 100644 index 000000000..2af3baef1 --- /dev/null +++ b/internal/schema/lint.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "errors" + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" +) + +var validJSONSchemaTypes = map[string]bool{ + "string": true, + "integer": true, + "number": true, + "boolean": true, + "array": true, + "object": true, +} + +var validAccessTokens = map[string]bool{ + "user": true, + "bot": true, +} + +// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice +// means the envelope is compliant. +func lintEnvelope(env Envelope) []error { + var errs []error + + // ---- L1: structural ---- + if env.Name == "" { + errs = append(errs, errors.New("L1: name must not be empty")) + } + if env.InputSchema == nil { + errs = append(errs, errors.New("L1: inputSchema must not be nil")) + } else { + if env.InputSchema.Type != "object" { + errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type)) + } + if env.InputSchema.Properties == nil { + errs = append(errs, errors.New("L1: inputSchema.properties must not be nil")) + } + } + if env.OutputSchema == nil { + errs = append(errs, errors.New("L1: outputSchema must not be nil")) + } else { + if env.OutputSchema.Type != "object" { + errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type)) + } + } + if env.Meta == nil { + errs = append(errs, errors.New("L1: _meta must not be nil")) + // Cannot continue meta-dependent checks + return errs + } + if env.Meta.EnvelopeVersion != "1.0" { + errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion)) + } + + // L1: validate every Property type recursively + if env.InputSchema != nil && env.InputSchema.Properties != nil { + validatePropertyTypes(env.InputSchema.Properties, &errs) + } + if env.OutputSchema != nil && env.OutputSchema.Properties != nil { + validatePropertyTypes(env.OutputSchema.Properties, &errs) + } + + // ---- L2: type-level consistency ---- + if env.InputSchema != nil && env.InputSchema.Properties != nil { + // Walk the whole property tree so format/min-max checks reach leaf + // fields nested under the params/data wrapper. + walkForL2(env.InputSchema.Properties, &errs) + // Top-level required keys must exist in top-level properties. + for _, r := range env.InputSchema.Required { + if _, ok := env.InputSchema.Properties.Map[r]; !ok { + errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r)) + } + } + } + + // ---- L3: cross-field self-consistency ---- + dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite + if env.Meta.Danger != dangerExpected { + errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk)) + } + + // `yes` lives at inputSchema.properties.yes (sibling of params/data), + // injected only for risk == RiskHighRiskWrite. + hasYes := false + if env.InputSchema != nil && env.InputSchema.Properties != nil { + _, hasYes = env.InputSchema.Properties.Map["yes"] + } + wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite + if hasYes != wantYes { + errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk)) + } + + if len(env.Meta.AccessTokens) == 0 { + errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty")) + } + for _, t := range env.Meta.AccessTokens { + if !validAccessTokens[t] { + errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t)) + } + } + + return errs +} + +// walkForL2 recursively applies per-field L2 checks (format:binary on +// non-string; minimum>=maximum) plus the sub-object required-exists invariant. +// Required only matters on object-typed Properties (e.g. the params / data +// wrappers); leaf scalars ignore it. +func walkForL2(props *OrderedProps, errs *[]error) { + if props == nil { + return + } + for _, k := range props.Order { + p := props.Map[k] + if p.Format == "binary" && p.Type != "string" { + *errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type)) + } + if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum { + *errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum)) + } + if len(p.Required) > 0 && p.Properties != nil { + for _, r := range p.Required { + if _, ok := p.Properties.Map[r]; !ok { + *errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k)) + } + } + } + if p.Properties != nil { + walkForL2(p.Properties, errs) + } + } +} + +// validatePropertyTypes walks an OrderedProps tree and asserts: +// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties) +// - array Properties have Items +// +// Errors are appended to *errs. +func validatePropertyTypes(props *OrderedProps, errs *[]error) { + if props == nil { + return + } + for _, k := range props.Order { + p := props.Map[k] + if p.Type != "" && !validJSONSchemaTypes[p.Type] { + *errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type)) + } + if p.Type == "array" && p.Items == nil { + *errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k)) + } + if p.Properties != nil { + validatePropertyTypes(p.Properties, errs) + } + // Validate the array-element schema itself, not only its child + // properties — a primitive element with an invalid type (e.g. + // `items.type = "list"`) would otherwise slip past lint. + if p.Items != nil { + validateItemSchema(k, p.Items, errs) + } + } +} + +// validateItemSchema checks a single array element schema for invalid types, +// then recurses into any further nested properties/items. +func validateItemSchema(parentKey string, item *Property, errs *[]error) { + if item.Type != "" && !validJSONSchemaTypes[item.Type] { + *errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type)) + } + if item.Type == "array" && item.Items == nil { + *errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey)) + } + if item.Properties != nil { + validatePropertyTypes(item.Properties, errs) + } + if item.Items != nil { + validateItemSchema(parentKey, item.Items, errs) + } +} + +// coverageBaseline is the per-metric warn threshold for L4 coverage checks. +// If the measured rate drops below the baseline, t.Logf emits a warning but +// does NOT fail the test. Adjust these constants upward as meta_data quality +// improves over time. +var coverageBaseline = map[string]float64{ + "description": 0.99, + "scopes": 1.00, + "doc_url": 0.98, + "risk": 0.96, +} + +// measureCoverage returns the non-empty rate for each tracked metric. +func measureCoverage(envs []Envelope) map[string]float64 { + if len(envs) == 0 { + return map[string]float64{ + "description": 0, + "scopes": 0, + "doc_url": 0, + "risk": 0, + } + } + total := float64(len(envs)) + var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64 + for _, e := range envs { + if e.Description != "" { + descNonEmpty++ + } + if e.Meta == nil { + continue + } + if len(e.Meta.Scopes) > 0 { + scopesNonEmpty++ + } + if e.Meta.DocURL != "" { + docURLNonEmpty++ + } + if e.Meta.Risk != "" { + riskNonEmpty++ + } + } + return map[string]float64{ + "description": descNonEmpty / total, + "scopes": scopesNonEmpty / total, + "doc_url": docURLNonEmpty / total, + "risk": riskNonEmpty / total, + } +} diff --git a/internal/schema/lint_test.go b/internal/schema/lint_test.go new file mode 100644 index 000000000..265c4c775 --- /dev/null +++ b/internal/schema/lint_test.go @@ -0,0 +1,379 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/registry" +) + +// validEnvelope builds a baseline valid envelope used as a starting point in +// negative tests below. +func validEnvelope() Envelope { + props := &OrderedProps{Map: map[string]Property{}} + return Envelope{ + Name: "x y z", + Description: "ok", + InputSchema: &InputSchema{ + Type: "object", + Properties: props, + }, + OutputSchema: &OutputSchema{ + Type: "object", + Properties: &OrderedProps{Map: map[string]Property{}}, + }, + Meta: &Meta{ + EnvelopeVersion: "1.0", + AccessTokens: []string{"user"}, + Risk: "read", + Danger: false, + }, + } +} + +func TestLintEnvelope_Valid(t *testing.T) { + env := validEnvelope() + errs := lintEnvelope(env) + if len(errs) != 0 { + t.Errorf("expected no errors, got: %v", errs) + } +} + +func TestLintEnvelope_L1_StructuralChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*Envelope) + wantSub string + }{ + { + name: "empty name", + mutate: func(e *Envelope) { e.Name = "" }, + wantSub: "name", + }, + { + name: "nil InputSchema", + mutate: func(e *Envelope) { e.InputSchema = nil }, + wantSub: "inputSchema", + }, + { + name: "inputSchema type not object", + mutate: func(e *Envelope) { e.InputSchema.Type = "string" }, + wantSub: "inputSchema.type", + }, + { + name: "nil OutputSchema", + mutate: func(e *Envelope) { e.OutputSchema = nil }, + wantSub: "outputSchema", + }, + { + name: "nil Meta", + mutate: func(e *Envelope) { e.Meta = nil }, + wantSub: "_meta", + }, + { + name: "wrong envelope version", + mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" }, + wantSub: "envelope_version", + }, + { + name: "invalid property type", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"x"} + e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"} + }, + wantSub: "invalid type", + }, + { + name: "array missing items", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"x"} + e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items + }, + wantSub: "items", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := validEnvelope() + tt.mutate(&env) + errs := lintEnvelope(env) + if len(errs) == 0 { + t.Fatalf("expected lint error, got none") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), tt.wantSub) { + found = true + break + } + } + if !found { + t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs) + } + }) + } +} + +func TestLintEnvelope_L2_TypeChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*Envelope) + wantSub string + }{ + { + name: "format binary on non-string", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"f"} + e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"} + }, + wantSub: "format: binary", + }, + { + name: "required key not in properties", + mutate: func(e *Envelope) { + e.InputSchema.Required = []string{"nonexistent"} + }, + wantSub: "required", + }, + { + name: "minimum >= maximum", + mutate: func(e *Envelope) { + min, max := 50.0, 10.0 + e.InputSchema.Properties.Order = []string{"n"} + e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max} + }, + wantSub: "minimum", + }, + { + // Regression guard: walkForL2 must recurse into the params/data + // sub-objects introduced by the 4-bucket inputSchema, not only the + // top-level Properties map. + name: "format binary on non-string inside params sub-object", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"params"} + e.InputSchema.Properties.Map["params"] = Property{ + Type: "object", + Properties: &OrderedProps{ + Order: []string{"id"}, + Map: map[string]Property{ + "id": {Type: "integer", Format: "binary"}, // wrong: binary on integer + }, + }, + } + }, + wantSub: "format: binary", + }, + { + name: "sub-object required references missing property", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"data"} + e.InputSchema.Properties.Map["data"] = Property{ + Type: "object", + Required: []string{"ghost"}, // not in properties below + Properties: &OrderedProps{ + Order: []string{"real"}, + Map: map[string]Property{"real": {Type: "string"}}, + }, + } + }, + wantSub: "ghost", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := validEnvelope() + tt.mutate(&env) + errs := lintEnvelope(env) + if len(errs) == 0 { + t.Fatalf("expected lint error, got none") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), tt.wantSub) { + found = true + break + } + } + if !found { + t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs) + } + }) + } +} + +func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*Envelope) + wantSub string + }{ + { + name: "danger true but risk read", + mutate: func(e *Envelope) { + e.Meta.Danger = true + e.Meta.Risk = "read" + }, + wantSub: "danger", + }, + { + name: "high-risk-write without yes", + mutate: func(e *Envelope) { + e.Meta.Risk = "high-risk-write" + e.Meta.Danger = true + // no yes injection + }, + wantSub: "yes", + }, + { + name: "yes injected but risk not high-risk-write", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"yes"} + e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"} + }, + wantSub: "yes", + }, + { + name: "empty access_tokens", + mutate: func(e *Envelope) { + e.Meta.AccessTokens = []string{} + }, + wantSub: "access_tokens", + }, + { + name: "invalid access_token value", + mutate: func(e *Envelope) { + e.Meta.AccessTokens = []string{"admin"} + }, + wantSub: "access_tokens", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := validEnvelope() + tt.mutate(&env) + errs := lintEnvelope(env) + if len(errs) == 0 { + t.Fatalf("expected lint error, got none") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), tt.wantSub) { + found = true + break + } + } + if !found { + t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs) + } + }) + } +} + +func TestMeasureCoverage_Counts(t *testing.T) { + envs := []Envelope{ + {Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}}, + {Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}}, + {Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}}, + } + c := measureCoverage(envs) + // 2/3 have non-empty description = ~0.667 + if c["description"] < 0.66 || c["description"] > 0.67 { + t.Errorf("description coverage = %v, want ~0.667", c["description"]) + } + // 2/3 have non-empty scopes + if c["scopes"] < 0.66 || c["scopes"] > 0.67 { + t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"]) + } + // 2/3 have doc_url + if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 { + t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"]) + } + // 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs) + if c["risk"] < 0.66 || c["risk"] > 0.67 { + t.Errorf("risk coverage = %v, want ~0.667", c["risk"]) + } +} + +// isKnownDataInconsistency returns true for lint errors that originate from +// real meta_data quality issues we still have to ship around in PR-1. With +// Task 17b the assembler walks embedded data only, so overlay-induced +// inconsistencies (risk-stripping) no longer appear; only the true embedded +// meta_data data-quality patterns remain. +// +// As meta_data quality improves this filter should be tightened/removed so +// TestAllEnvelopesPass becomes a hard gate again. +func isKnownDataInconsistency(msg string) bool { + switch { + case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`): + // Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query, + // drive.user.subscription, mail.user_mailbox.event.subscribe) where + // `risk="write"` but `danger` is missing (defaults to false). Needs a + // meta_data fix to set danger=true on these write methods. + return true + case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`): + // Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event, + // drive.metas.batch_query, mail.user_mailbox.templates.create) where + // `danger=true` but `risk` is missing (defaults to "read"). Needs a + // meta_data fix to set the proper risk level on these methods. + return true + case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"): + // meta_data sets min == max on some fields (e.g. + // mail.user_mailbox.event.subscribe.event_type), which the lint reads + // as min >= max. Real fix is in meta_data. + return true + } + return false +} + +func TestAllEnvelopesPass(t *testing.T) { + failCount := 0 + knownWarnings := 0 + knownEnvelopes := map[string]bool{} + // Use embedded data only so the gate is deterministic across machines + // (matches Task 17b: envelope assembly is overlay-independent). + for _, svc := range registry.EmbeddedServiceNames() { + spec := registry.EmbeddedSpec(svc) + envs := AssembleService(svc, spec, nil) + for _, env := range envs { + errs := lintEnvelope(env) + if len(errs) == 0 { + continue + } + var realErrs []error + for _, e := range errs { + if isKnownDataInconsistency(e.Error()) { + t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e) + knownWarnings++ + knownEnvelopes[env.Name] = true + continue + } + realErrs = append(realErrs, e) + } + if len(realErrs) > 0 { + for _, e := range realErrs { + t.Errorf("%s: %v", env.Name, e) + } + failCount++ + } + } + } + t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes)) + if failCount > 0 { + t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount) + } + + // L4 coverage report (warn-only via t.Logf) + all := AssembleAll(nil) + c := measureCoverage(all) + for metric, rate := range c { + baseline := coverageBaseline[metric] + if rate < baseline { + t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100) + } else { + t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100) + } + } +} diff --git a/internal/schema/path.go b/internal/schema/path.go new file mode 100644 index 000000000..a29b34136 --- /dev/null +++ b/internal/schema/path.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import "strings" + +// ParsePath normalizes the positional arguments of `lark-cli schema` into a +// slice of path segments. It accepts two equivalent forms: +// +// lark-cli schema im.messages.reply -> single arg, split on "." +// lark-cli schema im messages reply -> multiple args, used as-is +// lark-cli schema "im chat.members bots" is NOT a supported form; quote +// arguments individually if your shell needs it. Nested resources keep their +// internal dots (e.g. "chat.members"). +// +// Returns nil for zero args (bare invocation). +func ParsePath(args []string) []string { + switch len(args) { + case 0: + return nil + case 1: + if strings.Contains(args[0], ".") { + return strings.Split(args[0], ".") + } + return []string{args[0]} + default: + return args + } +} diff --git a/internal/schema/path_test.go b/internal/schema/path_test.go new file mode 100644 index 000000000..ec8934450 --- /dev/null +++ b/internal/schema/path_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "reflect" + "testing" +) + +func TestParsePath(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + {"empty args -> nil", nil, nil}, + {"empty slice -> nil", []string{}, nil}, + {"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}}, + {"single no-dot", []string{"im"}, []string{"im"}}, + {"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}}, + {"two args", []string{"im", "messages"}, []string{"im", "messages"}}, + {"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}}, + {"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParsePath(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/internal/schema/types.go b/internal/schema/types.go new file mode 100644 index 000000000..1081165c3 --- /dev/null +++ b/internal/schema/types.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" +) + +// Envelope is the MCP Tool spec contract for a single API method command. +type Envelope struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema *InputSchema `json:"inputSchema"` + OutputSchema *OutputSchema `json:"outputSchema"` + Meta *Meta `json:"_meta"` +} + +// InputSchema is JSON Schema Draft 2020-12 flattened. +// +// Required is intentionally rendered (no omitempty) so the envelope shape +// stays stable for AI consumers — an empty []string means "no required +// fields" rather than "schema is missing the field". +type InputSchema struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties *OrderedProps `json:"properties"` +} + +// OutputSchema wraps responseBody into a JSON Schema object. +type OutputSchema struct { + Type string `json:"type"` + Properties *OrderedProps `json:"properties"` +} + +// Property is one field's JSON Schema shape, recursive. +// +// Required is used when Property describes a nested object (e.g. the +// "params" / "data" sub-objects inside inputSchema): it lists which keys +// inside that object's Properties are mandatory. Leaf fields ignore it. +type Property struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []interface{} `json:"enum,omitempty"` + Default interface{} `json:"default,omitempty"` + Example interface{} `json:"example,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Format string `json:"format,omitempty"` + Required []string `json:"required,omitempty"` + Properties *OrderedProps `json:"properties,omitempty"` + Items *Property `json:"items,omitempty"` +} + +// Meta is the Lark-specific extension namespace. +type Meta struct { + EnvelopeVersion string `json:"envelope_version"` + Scopes []string `json:"scopes"` + RequiredScopes []string `json:"required_scopes"` + AccessTokens []string `json:"access_tokens"` + Danger bool `json:"danger"` + Risk string `json:"risk"` + DocURL string `json:"doc_url,omitempty"` + Affordance *Affordance `json:"affordance,omitempty"` +} + +// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded). +type Affordance struct { + UseWhen []string `json:"use_when,omitempty"` + DoNotUseWhen []string `json:"do_not_use_when,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Examples []AffordanceCase `json:"examples,omitempty"` + Related []string `json:"related,omitempty"` +} + +// AffordanceCase is one example entry. +type AffordanceCase struct { + Title string `json:"title"` + Input map[string]interface{} `json:"input"` +} + +// OrderedProps is map[string]Property with preserved key order on MarshalJSON. +// It is used wherever JSON output must reflect meta_data.json's natural field +// order rather than Go's default alphabetical map encoding. +type OrderedProps struct { + Order []string + Map map[string]Property +} + +// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but +// Map has entries, fall back to alphabetical key order over Map so callers +// that only populated Map (no explicit ordering) still see their fields. +func (o *OrderedProps) MarshalJSON() ([]byte, error) { + if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) { + return []byte("{}"), nil + } + keys := o.Order + if len(keys) == 0 { + keys = make([]string, 0, len(o.Map)) + for k := range o.Map { + keys = append(keys, k) + } + sort.Strings(keys) + } + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyJSON, err := json.Marshal(k) + if err != nil { + return nil, fmt.Errorf("marshal key %q: %w", k, err) + } + buf.Write(keyJSON) + buf.WriteByte(':') + valJSON, err := json.Marshal(o.Map[k]) + if err != nil { + return nil, fmt.Errorf("marshal value for %q: %w", k, err) + } + buf.Write(valJSON) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// UnmarshalJSON parses an object preserving key order via json.Decoder.Token(). +// Used for round-tripping in tests (and future golden update flows). +func (o *OrderedProps) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + tok, err := dec.Token() + if err != nil { + return err + } + if delim, ok := tok.(json.Delim); !ok || delim != '{' { + return fmt.Errorf("expected object, got %v", tok) + } + o.Order = nil + o.Map = make(map[string]Property) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key, ok := keyTok.(string) + if !ok { + return fmt.Errorf("expected string key, got %v", keyTok) + } + var prop Property + if err := dec.Decode(&prop); err != nil { + return err + } + o.Order = append(o.Order, key) + o.Map[key] = prop + } + if _, err := dec.Token(); err != nil { + return err + } + return nil +} diff --git a/internal/schema/types_test.go b/internal/schema/types_test.go new file mode 100644 index 000000000..ab1ae6c4e --- /dev/null +++ b/internal/schema/types_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "encoding/json" + "testing" +) + +// OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。 +func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) { + op := &OrderedProps{ + Order: []string{"z_first", "a_second", "m_third"}, + Map: map[string]Property{ + "z_first": {Type: "string"}, + "a_second": {Type: "integer"}, + "m_third": {Type: "boolean"}, + }, + } + b, err := json.Marshal(op) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + got := string(b) + want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}` + if got != want { + t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want) + } +} + +func TestOrderedProps_MarshalJSON_Empty(t *testing.T) { + op := &OrderedProps{Order: nil, Map: nil} + b, err := json.Marshal(op) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(b) != "{}" { + t.Errorf("empty OrderedProps should marshal to {}, got: %s", b) + } +} + +func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) { + in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`) + var op OrderedProps + if err := json.Unmarshal(in, &op); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if len(op.Order) != 2 { + t.Fatalf("expected 2 keys, got %d", len(op.Order)) + } + if op.Order[0] != "first" || op.Order[1] != "second" { + t.Errorf("unmarshal lost order: got %v", op.Order) + } + if op.Map["first"].Type != "string" { + t.Errorf("first.type mismatch") + } +} From e98471ce2621f0158e43cad76104213567e40888 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Wed, 27 May 2026 14:32:46 +0800 Subject: [PATCH 15/62] docs: document block anchor URLs in lark-doc skill (#1120) --- skills/lark-doc/SKILL.md | 5 +++++ skills/lark-doc/references/lark-doc-create.md | 1 - skills/lark-doc/references/lark-doc-fetch.md | 3 +-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index d017de483..3a5991c0c 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -32,6 +32,11 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen > - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。 ## 快速决策 +- 用户需要“某个 block 的直达链接 / 锚点链接”时:返回 `文档基础 URL#block_id`。如果当前只有文档 URL 没有 block_id,先用 `docs +fetch --detail with-ids` 拿到目标 block 的 id +- 例: + - 已知文档 URL = `https://xxx.feishu.cn/docx/doxcn123` + - 已知 block_id = `blkcn456` + - 应返回 `https://xxx.feishu.cn/docx/doxcn123#blkcn456` - 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节 - 写文档时,重要信息(核心流程、架构、对比、风险、路线图、关键指标、因果关系)优先规划为画板,不要只用文字或表格承载 - 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `完整 SVG`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 ``,再启动 SubAgent 读取 `lark-whiteboard` 写入 diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index cb8689d3e..5eb1254fb 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -86,4 +86,3 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项 - [`lark-doc-update.md`](lark-doc-update.md) — 更新文档 - [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档 - [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数 - diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index b16bc4b71..53b7b99cd 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -36,10 +36,9 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ | 意图 | `--detail` | 说明 | |------|-----------|------| | **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 | -| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `

`),可用于 `+update` 的 `--block-id` | +| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `

`),可用于 `+update` 的 `--block-id`,也可用于拼接 `文档URL#block_id` 形式的直达链接 | | **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 | - ## 选 `--scope`(读取范围) `--scope` 和 `--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。** From 17cbc13fcbd17bb6ef7fa6e79878ca4540c609cf Mon Sep 17 00:00:00 2001 From: AlbertSun Date: Wed, 27 May 2026 16:07:21 +0800 Subject: [PATCH 16/62] refactor(auth): drop duplicate top-level user fields in status (#1128) * opt: trim duplicate auth status info * fix: update signals of auth status workflow --- cmd/auth/status.go | 24 ------------------- .../lark-task/references/lark-task-create.md | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 20a8d4790..f0cf85e4d 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -61,7 +61,6 @@ func authStatusRun(opts *StatusOptions) error { diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify) result["identities"] = diagnostics result["identity"] = effectiveIdentity(diagnostics) - addLegacyUserFields(result, diagnostics.User) addEffectiveVerification(result, diagnostics) addStatusNote(result, diagnostics) @@ -86,29 +85,6 @@ func effectiveIdentity(d identitydiag.Result) string { } } -func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) { - if user.OpenID == "" { - return - } - result["userName"] = user.UserName - result["userOpenId"] = user.OpenID - if user.TokenStatus != "" { - result["tokenStatus"] = user.TokenStatus - } - if user.Scope != "" { - result["scope"] = user.Scope - } - if user.ExpiresAt != "" { - result["expiresAt"] = user.ExpiresAt - } - if user.RefreshExpiresAt != "" { - result["refreshExpiresAt"] = user.RefreshExpiresAt - } - if user.GrantedAt != "" { - result["grantedAt"] = user.GrantedAt - } -} - func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) { switch result["identity"] { case identityUser: diff --git a/skills/lark-task/references/lark-task-create.md b/skills/lark-task/references/lark-task-create.md index e281a57b9..7c946df4d 100644 --- a/skills/lark-task/references/lark-task-create.md +++ b/skills/lark-task/references/lark-task-create.md @@ -44,7 +44,7 @@ lark-cli task +create --summary "Test Task" --dry-run ## Workflow 1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary. - - **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter. + - **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter. 2. Execute `lark-cli task +create --summary "..." ...` 3. Report the result: task ID and summary. From 70081f62b14133446052e89d2c5c5591bffeace5 Mon Sep 17 00:00:00 2001 From: sang-neo03 Date: Wed, 27 May 2026 16:08:21 +0800 Subject: [PATCH 17/62] feat: use description and command in affordance example schema (#1126) Affordance examples previously carried a title plus a structured input object mirroring the inputSchema. Replace that with a description plus a command string holding a ready-to-run lark-cli invocation, which is what an AI agent driving the CLI actually consumes. No affordance data exists in the registry yet, so this only reshapes the consuming AffordanceCase type and its tests; the data pipeline (registry-config.yaml -> gen-registry.py -> meta_data.json) forwards the new keys verbatim. --- internal/schema/assembler_test.go | 5 +++-- internal/schema/types.go | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go index a935dafb0..6fa0e8bf2 100644 --- a/internal/schema/assembler_test.go +++ b/internal/schema/assembler_test.go @@ -575,7 +575,7 @@ func TestParseAffordance_FullPopulated(t *testing.T) { "do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"}, "prerequisites": []interface{}{"user 身份登录"}, "examples": []interface{}{ - map[string]interface{}{"title": "获取主日历", "input": map[string]interface{}{}}, + map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"}, }, "related": []interface{}{"calendars.list"}, } @@ -586,7 +586,8 @@ func TestParseAffordance_FullPopulated(t *testing.T) { if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { t.Errorf("UseWhen = %v", a.UseWhen) } - if len(a.Examples) != 1 || a.Examples[0].Title != "获取主日历" { + if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || + a.Examples[0].Command != "lark-cli calendar calendars primary" { t.Errorf("Examples = %+v", a.Examples) } if len(a.Related) != 1 || a.Related[0] != "calendars.list" { diff --git a/internal/schema/types.go b/internal/schema/types.go index 1081165c3..c8b1232c8 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -76,10 +76,11 @@ type Affordance struct { Related []string `json:"related,omitempty"` } -// AffordanceCase is one example entry. +// AffordanceCase is one example entry: a one-line description plus a +// ready-to-run lark-cli command string. type AffordanceCase struct { - Title string `json:"title"` - Input map[string]interface{} `json:"input"` + Description string `json:"description"` + Command string `json:"command"` } // OrderedProps is map[string]Property with preserved key order on MarshalJSON. From 30327abacb1f0d2c3db432c4988b98eb4b2242b7 Mon Sep 17 00:00:00 2001 From: sammi-bytedance Date: Wed, 27 May 2026 18:06:36 +0800 Subject: [PATCH 18/62] feat(im): enrich messages with reactions + output update_time (#1095) - Pull messages now auto-call im.reactions.batch_query and attach a reactions block (counts + details) to each message. Stops AI from misjudging "user already reacted" as "no response yet" and re-sending duplicate reactions. Server caps queries[] at 20 per call, so messages are split into batches of size <= 20. - Edited messages additionally surface update_time. The server echoes update_time == create_time for unedited messages too, so the field is only emitted when updated == true; otherwise every message output would look "edited". The value is read via an explicit string assertion + TrimSpace so empty strings are filtered properly (the previous `v != ""` was a no-op for non-string types). - All four message-pulling shortcuts (+messages-mget, +chat-messages-list, +messages-search, +threads-messages-list) get a --no-reactions opt-out flag for callers that want to skip the extra round-trip. - Each shortcut declares im:message.reactions:read on its UserScopes/BotScopes (or Scopes for the user-only search command) so the auth flow covers the new dependency. - Each shortcut's --dry-run output now lists the reactions/batch_query call (or omits it when --no-reactions is set), so callers can audit the full set of API calls before execution. - Warnings go through runtime.IO().ErrOut (forbidigo lint requires IOStreams over os.Stderr in shortcut code). - Duplicate message_id inputs (e.g. mget --message-ids om_a,om_a) attach the reactions block to every entry while still querying the API only once per distinct id. - EnrichReactions walks msg["thread_replies"] recursively, and mget/ chat-messages-list call it after ExpandThreadReplies, so replies receive reactions in the same batched call as their parent message. - When the batch_query call fails or returns per-message failures, the affected messages get reactions_error=true (mirroring the thread_replies_error flag from thread.go) so consumers can distinguish "fetch failed" from "no reactions exist" by reading stdout alone, without depending on the stderr warning channel. - lark-im skill docs: the default-enrichment contract lives in a standalone references/lark-im-message-enrichment.md so the generated SKILL.md can't strand it on regeneration. The four read references and the raw reactions API reference link to it, and the template source skill-template/domains/im.md carries a durable pointer. Change-Id: Ia9ea74b11945644262bb25c6503fb9b2003c6c98 --- shortcuts/im/convert_lib/content_convert.go | 14 + .../im/convert_lib/content_media_misc_test.go | 55 +++ shortcuts/im/convert_lib/reactions.go | 207 ++++++++++ shortcuts/im/convert_lib/reactions_test.go | 352 ++++++++++++++++++ shortcuts/im/im_chat_messages_list.go | 15 +- shortcuts/im/im_messages_mget.go | 15 +- shortcuts/im/im_messages_search.go | 13 +- shortcuts/im/im_threads_messages_list.go | 15 +- skill-template/domains/im.md | 4 + skills/lark-im/SKILL.md | 4 + .../references/lark-im-chat-messages-list.md | 2 + .../references/lark-im-message-enrichment.md | 28 ++ .../references/lark-im-messages-mget.md | 2 + .../references/lark-im-messages-search.md | 2 + .../lark-im/references/lark-im-reactions.md | 2 + .../lark-im-threads-messages-list.md | 2 + 16 files changed, 721 insertions(+), 11 deletions(-) create mode 100644 shortcuts/im/convert_lib/reactions.go create mode 100644 shortcuts/im/convert_lib/reactions_test.go create mode 100644 skills/lark-im/references/lark-im-message-enrichment.md diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 894df34d2..475f3c5a1 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -155,6 +155,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, } // Preserve API-provided fields (even if this formatter doesn't otherwise use them). + // update_time is only meaningful when the message was actually edited; + // the server echoes update_time == create_time for unedited messages, which + // would otherwise make every output look "updated" to downstream consumers. + if updated { + if v, ok := m["update_time"]; ok && v != nil { + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) != "" { + msg["update_time"] = common.FormatTime(s) + } + } else { + msg["update_time"] = common.FormatTime(v) + } + } + } if v, ok := m["chat_id"]; ok { msg["chat_id"] = v } diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index 85f26216d..3d0dbdaea 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) { } } +func TestFormatMessageItem_UpdateTime_Present(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_edit", + "updated": true, + "create_time": "1710500000", + "update_time": "1710600000", + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"text":"edited"}`}, + } + + got := FormatMessageItem(raw, nil) + want := common.FormatTime("1710600000") + if got["update_time"] != want { + t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want) + } +} + +func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_no_edit", + "updated": false, + "create_time": "1710500000", + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, nil) + if _, ok := got["update_time"]; ok { + t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"]) + } +} + +// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even +// for unedited messages, server returns update_time == create_time. We must +// NOT echo it through, otherwise every message looks "edited" to consumers. +// Gate the output on updated==true. +func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_unchanged", + "updated": false, + "create_time": "1710500000", + "update_time": "1710500000", // server echoes create_time + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, nil) + if v, ok := got["update_time"]; ok { + t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v) + } +} + func TestResolveAppLinkDomain(t *testing.T) { if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" { t.Fatalf("resolveAppLinkDomain(feishu) = %q", got) diff --git a/shortcuts/im/convert_lib/reactions.go b/shortcuts/im/convert_lib/reactions.go new file mode 100644 index 000000000..b697774df --- /dev/null +++ b/shortcuts/im/convert_lib/reactions.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "fmt" + "net/http" + + "github.com/larksuite/cli/shortcuts/common" +) + +// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[] +// length for POST /im/v1/messages/reactions/batch_query (see +// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs). +const reactionsBatchQueryMaxQueries = 20 + +// EnrichReactions enriches messages with their reactions by calling the +// im.reactions.batch_query API. Messages are modified in place: each message +// that the server returns reactions for gets a "reactions" map attached. +// +// Failure modes (warning to stderr + skip; never aborts main message output): +// - batch_query call fails (network, 5xx, scope insufficient, rate limited): +// each message in the failed batch is marked with "reactions_error": true +// so callers can distinguish "fetch failed" from "no reactions exist". +// - batch_query returns a partial result: only messages the server failed on +// get "reactions_error": true; the successful ones get the reactions block. +// +// The "reactions_error" flag mirrors the "thread_replies_error" pattern in +// thread.go so downstream consumers handle both enrichment failures uniformly. +// +// Output shape (only on messages that the server actually returned data for): +// +// "reactions": { +// "counts": [{"reaction_type": "SMILE", "count": 3}], +// "details": [{"reaction_id": "...", "emoji_type": "SMILE", +// "operator": {...}, "action_time": "..."}] +// } +// +// The server caps queries[] at 20 per call, so messages are split into +// batches of size <= 20 before invoking the API. +func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) { + if len(messages) == 0 { + return + } + + // Index messages by ID so we can merge reactions back later. + // A single message_id may appear more than once (e.g. mget --message-ids + // om_a,om_a); every occurrence must receive the reactions block, but the + // API should only be queried once per distinct id. + // Walks into msg["thread_replies"] recursively so replies attached by + // ExpandThreadReplies are enriched in the same batched call as their parent. + idIndex := make(map[string][]map[string]interface{}, len(messages)) + var ids []string + collectMessageNodes(messages, idIndex, &ids) + if len(ids) == 0 { + return + } + + for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries { + end := i + reactionsBatchQueryMaxQueries + if end > len(ids) { + end = len(ids) + } + fetchReactionsBatch(runtime, ids[i:end], idIndex) + } +} + +// collectMessageNodes walks messages (and any nested thread_replies) and +// records each map under its message_id. Distinct ids are appended to *ids in +// first-seen order so the API is queried at most once per id. +func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) { + for _, msg := range messages { + if id, _ := msg["message_id"].(string); id != "" { + if _, seen := idIndex[id]; !seen { + *ids = append(*ids, id) + } + idIndex[id] = append(idIndex[id], msg) + } + // thread_replies may arrive as a typed slice (set by ExpandThreadReplies) + // or as []interface{} (e.g. when produced via JSON round-trip). + switch nested := msg["thread_replies"].(type) { + case []map[string]interface{}: + collectMessageNodes(nested, idIndex, ids) + case []interface{}: + typed := make([]map[string]interface{}, 0, len(nested)) + for _, raw := range nested { + if m, ok := raw.(map[string]interface{}); ok { + typed = append(typed, m) + } + } + collectMessageNodes(typed, idIndex, ids) + } + } +} + +// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs +// and merges the results into idIndex. Failures are logged to stderr without +// aborting subsequent batches. +func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}) { + queries := make([]map[string]interface{}, 0, len(batchIDs)) + for _, id := range batchIDs { + queries = append(queries, map[string]interface{}{"message_id": id}) + } + + data, err := runtime.DoAPIJSON(http.MethodPost, + "/open-apis/im/v1/messages/reactions/batch_query", + nil, + map[string]interface{}{"queries": queries}, + ) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err) + markReactionsError(batchIDs, idIndex) + return + } + + countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"]) + detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"]) + + // Attach the merged reactions block to every message that had any data. + // Each id may map to >1 message map (duplicate input), so iterate the slice. + for _, id := range batchIDs { + msgs := idIndex[id] + if len(msgs) == 0 { + continue + } + counts := countsByMsg[id] + details := detailsByMsg[id] + if len(counts) == 0 && len(details) == 0 { + continue + } + block := make(map[string]interface{}, 2) + if len(counts) > 0 { + block["counts"] = counts + } + if len(details) > 0 { + block["details"] = details + } + for _, msg := range msgs { + msg["reactions"] = block + } + } + + // Surface per-message failures from the API response. + if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 { + var failedIDs []string + for _, raw := range fails { + item, _ := raw.(map[string]interface{}) + if id, _ := item["message_id"].(string); id != "" { + failedIDs = append(failedIDs, id) + } + } + if len(failedIDs) > 0 { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: reactions_partial_failed: %d message(s) failed (%v)\n", + len(failedIDs), failedIDs) + markReactionsError(failedIDs, idIndex) + } + } +} + +// markReactionsError flags every message map indexed under the given ids with +// reactions_error=true, so downstream consumers can distinguish "fetch failed" +// from "no reactions exist" by reading stdout alone. +func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) { + for _, id := range ids { + for _, msg := range idIndex[id] { + msg["reactions_error"] = true + } + } +} + +func groupReactionCounts(raw interface{}) map[string][]interface{} { + groups := map[string][]interface{}{} + items, _ := raw.([]interface{}) + for _, item := range items { + row, _ := item.(map[string]interface{}) + msgID, _ := row["message_id"].(string) + if msgID == "" { + continue + } + entries, _ := row["reaction_count"].([]interface{}) + if len(entries) == 0 { + continue + } + groups[msgID] = append(groups[msgID], entries...) + } + return groups +} + +func groupReactionDetails(raw interface{}) map[string][]interface{} { + groups := map[string][]interface{}{} + items, _ := raw.([]interface{}) + for _, item := range items { + row, _ := item.(map[string]interface{}) + msgID, _ := row["message_id"].(string) + if msgID == "" { + continue + } + entries, _ := row["message_reaction_items"].([]interface{}) + if len(entries) == 0 { + continue + } + groups[msgID] = append(groups[msgID], entries...) + } + return groups +} diff --git a/shortcuts/im/convert_lib/reactions_test.go b/shortcuts/im/convert_lib/reactions_test.go new file mode 100644 index 000000000..9a62b79df --- /dev/null +++ b/shortcuts/im/convert_lib/reactions_test.go @@ -0,0 +1,352 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "sort" + "strings" + "testing" +) + +// TestEnrichReactions_Success exercises the basic happy path: messages that +// carry reactions get a "reactions" field, messages without reactions stay +// untouched. +func TestEnrichReactions_Success(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") { + return nil, fmt.Errorf("unexpected path: %s", req.URL.Path) + } + var payload map[string]interface{} + body, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + if len(queries) != 2 { + t.Fatalf("queries size = %d, want 2", len(queries)) + } + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_a", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 3}, + }, + }, + }, + "success_msg_reaction_details": []interface{}{ + map[string]interface{}{ + "message_id": "om_a", + "message_reaction_items": []interface{}{ + map[string]interface{}{ + "reaction_id": "react_1", + "emoji_type": "SMILE", + "operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"}, + "action_time": "1710600000", + }, + }, + }, + }, + "fail_msg_reaction_details": []interface{}{}, + }, + }), nil + })) + + messages := []map[string]interface{}{ + {"message_id": "om_a"}, + {"message_id": "om_b"}, + } + + EnrichReactions(runtime, messages) + + reactionsA, ok := messages[0]["reactions"].(map[string]interface{}) + if !ok { + t.Fatalf("message om_a missing reactions field: %#v", messages[0]) + } + counts, _ := reactionsA["counts"].([]interface{}) + if len(counts) != 1 { + t.Fatalf("om_a counts = %d, want 1", len(counts)) + } + details, _ := reactionsA["details"].([]interface{}) + if len(details) != 1 { + t.Fatalf("om_a details = %d, want 1", len(details)) + } + + if _, ok := messages[1]["reactions"]; ok { + t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1]) + } +} + +// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side +// max for batch_query). +func TestEnrichReactions_BatchSize(t *testing.T) { + var observedBatchSizes []int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + observedBatchSizes = append(observedBatchSizes, len(queries)) + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }), nil + })) + + messages := make([]map[string]interface{}, 25) + for i := range messages { + messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)} + } + + EnrichReactions(runtime, messages) + + if want := []int{20, 5}; !reflect.DeepEqual(observedBatchSizes, want) { + t.Fatalf("batch sizes = %v, want %v", observedBatchSizes, want) + } +} + +// TestEnrichReactions_APIFailure: when the API call fails, messages stay +// without a reactions field but get marked with reactions_error=true so +// downstream consumers can distinguish "fetch failed" from "no reactions". +// Mirrors the thread_replies_error pattern in thread.go. +func TestEnrichReactions_APIFailure(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("simulated network error") + })) + + messages := []map[string]interface{}{ + {"message_id": "om_a"}, + {"message_id": "om_b"}, + } + + EnrichReactions(runtime, messages) + + for _, m := range messages { + if _, ok := m["reactions"]; ok { + t.Fatalf("message %v should have no reactions after API failure", m["message_id"]) + } + if v, _ := m["reactions_error"].(bool); !v { + t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v", + m["message_id"], m["reactions_error"]) + } + } +} + +// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry +// for one ID, that message gets reactions_error=true while the rest stay +// clean (no error flag) and keep their normal reactions block. +func TestEnrichReactions_PartialFailure(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_ok", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 1}, + }, + }, + }, + "fail_msg_reaction_details": []interface{}{ + map[string]interface{}{"message_id": "om_bad"}, + }, + }, + }), nil + })) + + ok := map[string]interface{}{"message_id": "om_ok"} + bad := map[string]interface{}{"message_id": "om_bad"} + EnrichReactions(runtime, []map[string]interface{}{ok, bad}) + + if _, has := ok["reactions"]; !has { + t.Fatalf("om_ok should have reactions: %#v", ok) + } + if v, _ := ok["reactions_error"].(bool); v { + t.Fatalf("om_ok must not carry reactions_error: %#v", ok) + } + if _, has := bad["reactions"]; has { + t.Fatalf("om_bad should have no reactions block: %#v", bad) + } + if v, _ := bad["reactions_error"].(bool); !v { + t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"]) + } +} + +// TestEnrichReactions_EmptyMessages: no messages -> no API call at all. +func TestEnrichReactions_EmptyMessages(t *testing.T) { + called := false + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + called = true + return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + })) + + EnrichReactions(runtime, nil) + EnrichReactions(runtime, []map[string]interface{}{}) + + if called { + t.Fatalf("API should not be called when messages list is empty") + } +} + +// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id +// (defensive) should not crash and not be sent in queries. +func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) { + var sentIDs []string + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + for _, q := range queries { + qm, _ := q.(map[string]interface{}) + id, _ := qm["message_id"].(string) + sentIDs = append(sentIDs, id) + } + return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + })) + + messages := []map[string]interface{}{ + {"message_id": "om_a"}, + {}, // no message_id + {"message_id": ""}, + {"message_id": "om_b"}, + } + + EnrichReactions(runtime, messages) + + if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) { + t.Fatalf("sent IDs = %v, want %v", sentIDs, want) + } +} + +// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent +// message must also be enriched, in the same batch_query call as the parent — +// otherwise the parent gets reactions but its replies don't, leaving the output +// inconsistent. +func TestEnrichReactions_WalksThreadReplies(t *testing.T) { + var observedQueriedIDs []string + var observedCallCount int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + observedCallCount++ + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + for _, q := range queries { + qm, _ := q.(map[string]interface{}) + id, _ := qm["message_id"].(string) + observedQueriedIDs = append(observedQueriedIDs, id) + } + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_top", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 1}, + }, + }, + map[string]interface{}{ + "message_id": "om_reply1", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2}, + }, + }, + map[string]interface{}{ + "message_id": "om_reply2", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "HEART", "count": 3}, + }, + }, + }, + }, + }), nil + })) + + reply1 := map[string]interface{}{"message_id": "om_reply1"} + reply2 := map[string]interface{}{"message_id": "om_reply2"} + top := map[string]interface{}{ + "message_id": "om_top", + "thread_replies": []map[string]interface{}{reply1, reply2}, + } + messages := []map[string]interface{}{top} + + EnrichReactions(runtime, messages) + + if observedCallCount != 1 { + t.Fatalf("expected 1 batched API call, got %d", observedCallCount) + } + sort.Strings(observedQueriedIDs) + if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) { + t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want) + } + + if _, ok := top["reactions"]; !ok { + t.Fatalf("top message missing reactions") + } + if _, ok := reply1["reactions"]; !ok { + t.Fatalf("reply1 missing reactions — thread_replies were not walked") + } + if _, ok := reply2["reactions"]; !ok { + t.Fatalf("reply2 missing reactions — thread_replies were not walked") + } +} + +// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct +// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a), +// both maps must receive the same reactions block, and the API must be queried +// for the id only once. +func TestEnrichReactions_DuplicateMessageID(t *testing.T) { + var observedQueriesPerCall []int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + observedQueriesPerCall = append(observedQueriesPerCall, len(queries)) + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_a", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 2}, + }, + }, + }, + }, + }), nil + })) + + first := map[string]interface{}{"message_id": "om_a"} + second := map[string]interface{}{"message_id": "om_a"} + other := map[string]interface{}{"message_id": "om_b"} + messages := []map[string]interface{}{first, other, second} + + EnrichReactions(runtime, messages) + + if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) { + t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want) + } + + firstReactions, firstOK := first["reactions"] + secondReactions, secondOK := second["reactions"] + if !firstOK { + t.Fatalf("first om_a entry missing reactions") + } + if !secondOK { + t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped") + } + if !reflect.DeepEqual(firstReactions, secondReactions) { + t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions) + } +} diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index bb247a9b5..9c382a273 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{ Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination", Risk: "read", Scopes: []string{"im:message:readonly"}, - UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"}, - BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"}, + UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"}, + BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ @@ -34,6 +34,7 @@ var ImChatMessageList = common.Shortcut{ {Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}}, {Name: "page-size", Default: "50", Desc: "page size (1-50)"}, {Name: "page-token", Desc: "pagination token for next page"}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { d := common.NewDryRunAPI() @@ -54,7 +55,12 @@ var ImChatMessageList = common.Shortcut{ dryParams[k] = vs[0] } } - return d.GET("/open-apis/im/v1/messages").Params(dryParams) + d = d.GET("/open-apis/im/v1/messages").Params(dryParams) + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { // Under bot identity, --user-id is not supported; require --chat-id only. @@ -121,6 +127,9 @@ var ImChatMessageList = common.Shortcut{ convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, messages) + } outData := map[string]interface{}{ "messages": messages, diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index a8d9ade72..823f1d608 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -22,16 +22,22 @@ var ImMessagesMGet = common.Shortcut{ Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies", Risk: "read", Scopes: []string{"im:message:readonly"}, - UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"}, - BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"}, + UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"}, + BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { ids := common.SplitCSV(runtime.Str("message-ids")) - return common.NewDryRunAPI().GET(buildMGetURL(ids)) + d := common.NewDryRunAPI().GET(buildMGetURL(ids)) + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { ids := common.SplitCSV(runtime.Str("message-ids")) @@ -69,6 +75,9 @@ var ImMessagesMGet = common.Shortcut{ convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, messages) + } outData := map[string]interface{}{ "messages": messages, diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index 48dbca06d..bc5fe5765 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{ Command: "+messages-search", Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query", Risk: "read", - Scopes: []string{"search:message", "contact:user.basic_profile:readonly"}, + Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ @@ -49,6 +49,7 @@ var ImMessagesSearch = common.Shortcut{ {Name: "page-token", Desc: "page token"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate search results"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { req, err := buildMessagesSearchRequest(runtime) @@ -68,12 +69,17 @@ var ImMessagesSearch = common.Shortcut{ } else { d = d.Desc("Step 1: search messages") } - return d. + d = d. POST("/open-apis/im/v1/messages/search"). Params(dryParams). Body(req.body). Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)"). Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context") + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { _, err := buildMessagesSearchRequest(runtime) @@ -184,6 +190,9 @@ var ImMessagesSearch = common.Shortcut{ // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) convertlib.ResolveSenderNames(runtime, enriched, nameCache) convertlib.AttachSenderNames(enriched, nameCache) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, enriched) + } outData := map[string]interface{}{ "messages": enriched, diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 538117a94..79ba8f58d 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{ Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination", Risk: "read", Scopes: []string{"im:message:readonly"}, - UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"}, - BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"}, + UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"}, + BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ @@ -33,6 +33,7 @@ var ImThreadsMessagesList = common.Shortcut{ {Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}}, {Name: "page-size", Default: "50", Desc: "page size (1-500)"}, {Name: "page-token", Desc: "page token"}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { threadFlag := runtime.Str("thread") @@ -65,10 +66,15 @@ var ImThreadsMessagesList = common.Shortcut{ params["page_token"] = pageToken } - return d. + d = d. GET("/open-apis/im/v1/messages"). Params(params). Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr) + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { threadId := runtime.Str("thread") @@ -124,6 +130,9 @@ var ImThreadsMessagesList = common.Shortcut{ // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, messages) + } outData := map[string]interface{}{ "thread_id": threadId, diff --git a/skill-template/domains/im.md b/skill-template/domains/im.md index 7e7dce0d7..712e34be5 100644 --- a/skill-template/domains/im.md +++ b/skill-template/domains/im.md @@ -33,6 +33,10 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis **Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access. +### Default message enrichment (reactions / update_time) + +The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md). + ### Card Messages (Interactive) Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr. diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 0d127b244..b19e72018 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -47,6 +47,10 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis **Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access. +### Default message enrichment (reactions / update_time) + +The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md). + ### Card Messages (Interactive) Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr. diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index a19629e3f..d57e8c651 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -4,6 +4,8 @@ Fetch the message list for a conversation. Supports both group chats and direct messages. +By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract. + This skill maps to the shortcut: `lark-cli im +chat-messages-list` (internally calls `GET /open-apis/im/v1/messages`, and automatically resolves the p2p chat_id when needed). ## Commands diff --git a/skills/lark-im/references/lark-im-message-enrichment.md b/skills/lark-im/references/lark-im-message-enrichment.md new file mode 100644 index 000000000..ee3935fd5 --- /dev/null +++ b/skills/lark-im/references/lark-im-message-enrichment.md @@ -0,0 +1,28 @@ +# im default message enrichment (reactions / update_time) + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. + +This is the single source of truth for the automatic message-enrichment contract shared by the four message-pulling shortcuts — [`+messages-mget`](lark-im-messages-mget.md), [`+chat-messages-list`](lark-im-chat-messages-list.md), [`+messages-search`](lark-im-messages-search.md), [`+threads-messages-list`](lark-im-threads-messages-list.md). They automatically attach `reactions` and `update_time` to each returned message, so callers do **not** need to invoke the raw [`im.reactions.batch_query`](lark-im-reactions.md) API separately. + +- **`reactions`** — populated from one batched `im.reactions.batch_query` call as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched in the **same batched call** as their parent, so outer and inner messages follow identical semantics. +- **`update_time`** — emitted only when `updated == true` (message was actually edited). The server echoes `update_time == create_time` for unedited messages too, but the CLI gates that output away so consumers don't misread every message as "edited". +- **Opt-out** — each shortcut accepts `--no-reactions` to skip the extra round-trip when the caller only needs message bodies. + +## Scope requirement + +The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run: + +```bash +lark-cli auth login --scope "im:message.reactions:read" +``` + +## Data contract — missing field ≠ fetch failure + +| Situation | Output | +|---|---| +| Message has no reactions | `reactions` field is omitted (not `{}`, not an empty list) | +| Message was never edited | `update_time` field is omitted | +| Whole batch failed | Messages in that batch carry no `reactions`; one line on stderr: `warning: reactions_batch_query_failed: ...` | +| Some message IDs failed | Failed IDs go to stderr: `warning: reactions_partial_failed: N message(s) failed (...)` | + +When deciding "has the user already reacted?", branch on the **presence of the `reactions` field plus its `counts` contents**, not on whether a value is `null` — the field's absence means "no data attached" (which usually means "no reactions exist"), not "fetch failed". diff --git a/skills/lark-im/references/lark-im-messages-mget.md b/skills/lark-im/references/lark-im-messages-mget.md index 45cdd2c5b..7583a0713 100644 --- a/skills/lark-im/references/lark-im-messages-mget.md +++ b/skills/lark-im/references/lark-im-messages-mget.md @@ -4,6 +4,8 @@ Fetch message details in batch. Given a list of message IDs, this returns the full content for multiple messages in one call and automatically resolves sender names. +By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract. + > **Supports both `--as user` (default) and `--as bot`.** This skill maps to the shortcut: `lark-cli im +messages-mget` (internally calls `GET /open-apis/im/v1/messages/mget`). diff --git a/skills/lark-im/references/lark-im-messages-search.md b/skills/lark-im/references/lark-im-messages-search.md index 7faa30486..594ecca35 100644 --- a/skills/lark-im/references/lark-im-messages-search.md +++ b/skills/lark-im/references/lark-im-messages-search.md @@ -4,6 +4,8 @@ Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context. +By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract. + > **User identity only** (`--as user`). Bot identity is not supported. This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context). diff --git a/skills/lark-im/references/lark-im-reactions.md b/skills/lark-im/references/lark-im-reactions.md index ffd7ef96a..4920a17c9 100644 --- a/skills/lark-im/references/lark-im-reactions.md +++ b/skills/lark-im/references/lark-im-reactions.md @@ -2,6 +2,8 @@ > **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. +> **Heads-up — don't reach for `batch_query` by default.** The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) already call `im.reactions.batch_query` automatically and attach the result as a `reactions` block on each message (replies inside `thread_replies` included). Use those shortcuts for any "read reactions of messages I'm already pulling" task. Reach for the raw `batch_query` API only when you have a standalone `message_id` outside that pull flow. See the main [message enrichment](lark-im-message-enrichment.md) for the contract. + This reference is the shared annotation target for the IM reaction APIs: - `im.reactions.create` diff --git a/skills/lark-im/references/lark-im-threads-messages-list.md b/skills/lark-im/references/lark-im-threads-messages-list.md index 2c2186b94..704171b7e 100644 --- a/skills/lark-im/references/lark-im-threads-messages-list.md +++ b/skills/lark-im/references/lark-im-threads-messages-list.md @@ -4,6 +4,8 @@ Fetch the reply message list inside a thread. When `im +chat-messages-list` returns messages that include a `thread_id` field, use this command to inspect all replies in that thread. +By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract. + This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internally calls `GET /open-apis/im/v1/messages` with `container_id_type=thread` to fetch thread messages). ## Commands From ab94ee9f549643d2392e756f7a83e964bb7a3eee Mon Sep 17 00:00:00 2001 From: xukuncx Date: Wed, 27 May 2026 18:12:41 +0800 Subject: [PATCH 19/62] feat(mail): add +draft-send shortcut for batch draft sending (#1017) Add `lark-cli mail +draft-send` shortcut that takes one or more existing draft IDs and sends each via POST /drafts/:draft_id/send sequentially. Per-draft failures are isolated and aggregated into a structured output; fatal failures (auth, permission, network, mailbox quota) abort the entire batch immediately while recoverable failures honor --stop-on-error. Also extend internal/output with six mail-send-specific errno constants (LarkErrMailboxNotFound=4013, LarkErrMailSendQuota{User,UserExt,TenantExt}, LarkErrMailQuota, LarkErrTenantStorageLimit) consumed by isFatalSendErr. Risk is "high-risk-write" so the framework's --yes gate applies; the shortcut declares only the minimal mail:user_mailbox.message:send scope to avoid asking users for permissions it does not need. --- internal/output/lark_errors.go | 13 + internal/output/lark_errors_test.go | 26 + shortcuts/mail/mail_draft_send.go | 330 ++++++ shortcuts/mail/mail_draft_send_test.go | 942 ++++++++++++++++++ shortcuts/mail/shortcuts.go | 1 + tests/cli_e2e/mail/coverage.md | 8 +- .../mail/mail_draft_send_dryrun_test.go | 124 +++ .../mail/mail_draft_send_workflow_test.go | 166 +++ 8 files changed, 1607 insertions(+), 3 deletions(-) create mode 100644 shortcuts/mail/mail_draft_send.go create mode 100644 shortcuts/mail/mail_draft_send_test.go create mode 100644 tests/cli_e2e/mail/mail_draft_send_dryrun_test.go create mode 100644 tests/cli_e2e/mail/mail_draft_send_workflow_test.go diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index 83ecda9f0..62a5e057e 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -66,6 +66,19 @@ const ( // IM resource ownership mismatch. LarkErrOwnershipMismatch = 231205 + + // Mail send: account / mailbox-level failures returned by + // POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send. + // Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code + // because ErrAPI preserves Detail.Code exactly as returned by the server. + // These codes indicate the entire batch will keep failing identically and + // are consumed by shortcuts/mail.isFatalSendErr to abort early. + LarkErrMailboxNotFound = 1234013 // mailbox not found or not active + LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded + LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded + LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded + LarkErrMailQuota = 1236010 // mail quota limit + LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded ) // legacyHints supplies the per-code actionable hint string for the legacy diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index 3e8c0e67f..9f7fae8d2 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) { } } +func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + got int + want int + }{ + {name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013}, + {name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007}, + {name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008}, + {name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009}, + {name: "mail quota", got: LarkErrMailQuota, want: 1236010}, + {name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.got != tt.want { + t.Fatalf("code=%d, want %d", tt.got, tt.want) + } + }) + } +} + // TestClassifyLarkError_WikiLockContention verifies the wiki write-lock // contention error (131009) maps to an actionable retry hint instead of // a generic "api_error". Surfaces during concurrent wiki +node-create diff --git a/shortcuts/mail/mail_draft_send.go b/shortcuts/mail/mail_draft_send.go new file mode 100644 index 000000000..e91c55931 --- /dev/null +++ b/shortcuts/mail/mail_draft_send.go @@ -0,0 +1,330 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MaxBatchSendDrafts caps the number of draft IDs accepted in a single +// +draft-send invocation. The limit is purely client-side: it bounds command- +// line length comfortably below ARG_MAX and keeps the failure blast radius of +// a single batch small. It is intentionally local to this shortcut (rather +// than living in limits.go) because no other shortcut shares the semantics. +const MaxBatchSendDrafts = 50 + +// sentDraft is the per-draft success entry in the +draft-send aggregated +// output. message_id and thread_id come from the server response of +// POST /drafts/:draft_id/send. +type sentDraft struct { + DraftID string `json:"draft_id"` + MessageID string `json:"message_id"` + ThreadID string `json:"thread_id,omitempty"` +} + +// failedDraft is the per-draft failure entry. error is the +// human-readable err.Error() string (typically including ClassifyLarkError +// hints); v2 may surface a structured errno field separately once the server- +// side mapping stabilises (see tech-design "待确认事项"). +type failedDraft struct { + DraftID string `json:"draft_id"` + Error string `json:"error"` +} + +// batchSendOutput is the JSON envelope data shape: +// +// { +// "mailbox_id": "me", +// "total": 3, +// "success_count": 2, +// "failure_count": 1, +// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...], +// "failed":[{"draft_id":..., "error":...}] +// } +// +// failed is marked omitempty so a fully successful batch returns a clean shape +// without an empty array. +type batchSendOutput struct { + MailboxID string `json:"mailbox_id"` + Total int `json:"total"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + Sent []sentDraft `json:"sent"` + Failed []failedDraft `json:"failed,omitempty"` +} + +// MailDraftSend is the `+draft-send` shortcut: send N existing drafts +// sequentially via POST /drafts/:draft_id/send, isolating per-draft failures. +// Risk is "high-risk-write"; callers must pass --yes. User identity only — +// drafts are user-owned resources and bot has no coherent semantics here. +// +// Output schema is the batchSendOutput type above. Partial failures (any +// failed[]) return exit 1 with envelope.error.type="partial_failure" so that +// agents can distinguish "all sent" from "some sent" without parsing the +// success_count field. +var MailDraftSend = common.Shortcut{ + Service: "mail", + Command: "+draft-send", + Description: "Send one or more existing mail drafts sequentially. Calls " + + "POST /drafts/:draft_id/send for each input ID, isolates per-draft " + + "failures, and aggregates the results. Use after the drafts have " + + "already been created (via the Lark client, +draft-create, or the " + + "drafts.create API).", + Risk: "high-risk-write", + Scopes: []string{"mail:user_mailbox.message:send"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."}, + {Name: "draft-id", Type: "string_slice", Required: true, + Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."}, + {Name: "stop-on-error", Type: "bool", + Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " + + "Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " + + "regardless of this flag."}, + }, + Validate: validateDraftSend, + DryRun: dryRunDraftSend, + Execute: executeDraftSend, +} + +// executeDraftSend runs the +draft-send command: +// +// 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID). +// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap, +// no empty elements). +// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via +// runtime.CallAPI. Per-draft outcomes: +// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error). +// - recoverable err → append to failed[]; honor --stop-on-error. +// - success + automation_send_disable signal → return immediately with +// ExitAPI/"automation_send_disabled". +// - success → append to sent[]. +// 4. Emit batchSendOutput via runtime.Out. +// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1. +func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error { + mailboxID := resolveComposeMailboxID(rt) + draftIDs, err := normalizedDraftSendIDs(rt) + if err != nil { + return err + } + + out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)} + stopOnErr := rt.Bool("stop-on-error") + for i, id := range draftIDs { + idx := i + 1 + writeDraftSendProgressf(rt, "[%d/%d] sending draft %s", + idx, len(draftIDs), sanitizeForSingleLine(id)) + // Direct CallAPI rather than draftpkg.Send: this shortcut never sends + // a body, so the helper's send_time-aware envelope would add no value. + data, err := rt.CallAPI("POST", + mailboxPath(mailboxID, "drafts", id, "send"), nil, nil) + if err != nil { + if isFatalSendErr(err) { + writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s", + idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error())) + hadProgress := out.hasProgress() + out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()}) + if hadProgress { + emitDraftSendOutput(rt, &out) + } + // Account- / mailbox-level failures (auth, permission, network, + // quota) will repeat identically for every remaining draft — + // abort immediately so the caller sees a single clear error + // instead of 100 redundant failed[] entries. + return err + } + writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s", + idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error())) + out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()}) + if stopOnErr { + break + } + continue + } + if reason := extractAutomationDisabledReason(data); reason != "" { + err := output.Errorf(output.ExitAPI, "automation_send_disabled", + "automation send is disabled for this mailbox: %s", reason) + writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s", + idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error())) + if out.hasProgress() { + out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()}) + emitDraftSendOutput(rt, &out) + } + // HTTP success (code: 0) but the backend signaled automation send + // is disabled — every subsequent send will fail the same way, so + // abort the batch with a single descriptive error. + return err + } + s := sentDraft{DraftID: id} + if v, ok := data["message_id"].(string); ok { + s.MessageID = v + } + if v, ok := data["thread_id"].(string); ok { + s.ThreadID = v + } + out.Sent = append(out.Sent, s) + if s.MessageID != "" { + writeDraftSendProgressf(rt, "[%d/%d] sent draft %s message_id=%s", + idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(s.MessageID)) + } else { + writeDraftSendProgressf(rt, "[%d/%d] sent draft %s", + idx, len(draftIDs), sanitizeForSingleLine(id)) + } + } + emitDraftSendOutput(rt, &out) + + if out.FailureCount == 0 { + return nil + } + return output.Errorf(output.ExitAPI, "partial_failure", + "%d of %d drafts failed to send", out.FailureCount, out.Total) +} + +// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID, +// in input order, with a header description summarising the batch size. +func dryRunDraftSend(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveComposeMailboxID(rt) + draftIDs, _ := normalizedDraftSendIDs(rt) + api := common.NewDryRunAPI().Desc(fmt.Sprintf( + "Send %d existing drafts sequentially", len(draftIDs))) + for _, id := range draftIDs { + api = api.POST(mailboxPath(mailboxID, "drafts", id, "send")) + } + return api +} + +func validateDraftSend(ctx context.Context, rt *common.RuntimeContext) error { + _, err := normalizedDraftSendIDs(rt) + return err +} + +func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) { + return normalizeDraftSendIDs(rt.StrSlice("draft-id")) +} + +func normalizeDraftSendIDs(draftIDs []string) ([]string, error) { + if len(draftIDs) == 0 { + return nil, output.ErrValidation("--draft-id is required") + } + + normalized := make([]string, 0, len(draftIDs)) + seen := make(map[string]struct{}, len(draftIDs)) + for _, id := range draftIDs { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return nil, output.ErrValidation("--draft-id contains empty value") + } + if _, ok := seen[trimmed]; ok { + return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed) + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + if len(normalized) > MaxBatchSendDrafts { + return nil, output.ErrValidation( + "too many drafts: %d > %d (split into multiple batches)", + len(normalized), MaxBatchSendDrafts) + } + return normalized, nil +} + +func (out *batchSendOutput) hasProgress() bool { + return len(out.Sent) > 0 || len(out.Failed) > 0 +} + +func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) { + out.SuccessCount = len(out.Sent) + out.FailureCount = len(out.Failed) + rt.Out(*out, nil) +} + +func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) { + if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil { + return + } + fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "mail +draft-send: "+format+"\n", args...) +} + +// isFatalSendErr reports whether err is an account- or mailbox-level failure +// that will repeat identically for every subsequent draft. Fatal errors +// bypass --stop-on-error and immediately abort the batch. +// +// Trigger conditions: +// +// - err does not unwrap to an *output.ExitError, or its Detail is missing: +// unknown shapes are treated as fatal so they cannot accidentally +// accumulate into failed[] for every remaining draft. +// - Detail.Type ∈ {"auth", "app_status", "config", "permission", +// "rate_limit", "network"}: token, scope, app-installation problems, +// throttling, and connectivity are account-level. +// - Code == output.ExitNetwork: connectivity loss is account-level. +// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser, +// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt, +// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota +// exhaustion is account-level. +func isFatalSendErr(err error) bool { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + return true + } + switch exitErr.Detail.Type { + case "auth", "app_status", "config": + return true + case "permission", "rate_limit", "network": + return true + } + if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) { + return true + } + switch exitErr.Detail.Code { + case output.LarkErrMailboxNotFound, + output.LarkErrMailSendQuotaUser, + output.LarkErrMailSendQuotaUserExt, + output.LarkErrMailSendQuotaTenantExt, + output.LarkErrMailQuota, + output.LarkErrTenantStorageLimit: + return true + } + return false +} + +func wrapsExitCode(err error, code int) bool { + for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) { + if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code { + return true + } + } + return false +} + +// extractAutomationDisabledReason returns the human-readable reason when the +// send succeeded at HTTP level (code: 0) but the backend reports that +// automation send is disabled for this mailbox. An empty return value means +// automation send is enabled. +// +// The data["automation_send_disable"] payload is best-effort: a malformed +// shape or missing reason still produces a generic non-empty message so the +// caller can surface the disabled status to the user instead of silently +// continuing. +func extractAutomationDisabledReason(data map[string]interface{}) string { + ad, ok := data["automation_send_disable"] + if !ok { + return "" + } + m, ok := ad.(map[string]interface{}) + if !ok { + return "automation send disabled (no reason provided)" + } + if reason, ok := m["reason"].(string); ok && strings.TrimSpace(reason) != "" { + return strings.TrimSpace(reason) + } + return "automation send disabled (no reason provided)" +} diff --git a/shortcuts/mail/mail_draft_send_test.go b/shortcuts/mail/mail_draft_send_test.go new file mode 100644 index 000000000..936d0574a --- /dev/null +++ b/shortcuts/mail/mail_draft_send_test.go @@ -0,0 +1,942 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestMailDraftSend_Metadata pins the public surface of the +draft-send +// shortcut: command name, risk level, scopes, auth type, and the three +// declared flags. Changing any of these is a public-contract change and must +// be intentional. +func TestMailDraftSend_Metadata(t *testing.T) { + if MailDraftSend.Service != "mail" { + t.Errorf("Service = %q, want %q", MailDraftSend.Service, "mail") + } + if MailDraftSend.Command != "+draft-send" { + t.Errorf("Command = %q, want %q", MailDraftSend.Command, "+draft-send") + } + if MailDraftSend.Risk != "high-risk-write" { + t.Errorf("Risk = %q, want %q", MailDraftSend.Risk, "high-risk-write") + } + if !MailDraftSend.HasFormat { + t.Error("HasFormat must be true so --format is auto-injected") + } + if len(MailDraftSend.AuthTypes) != 1 || MailDraftSend.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v, want [user]", MailDraftSend.AuthTypes) + } + // Minimum-permission rule: only :send. Adding :modify or :readonly here is + // an explicit scope-policy regression. + if len(MailDraftSend.Scopes) != 1 || MailDraftSend.Scopes[0] != "mail:user_mailbox.message:send" { + t.Errorf("Scopes = %v, want [mail:user_mailbox.message:send]", MailDraftSend.Scopes) + } + + flagByName := map[string]common.Flag{} + for _, fl := range MailDraftSend.Flags { + flagByName[fl.Name] = fl + } + mailbox, ok := flagByName["mailbox"] + if !ok { + t.Fatal("missing --mailbox flag") + } + if mailbox.Required { + t.Error("--mailbox must NOT be Required (defaults to me via resolveComposeMailboxID)") + } + if mailbox.Default != "" { + t.Errorf("--mailbox Default should be empty (let resolveComposeMailboxID supply 'me'); got %q", mailbox.Default) + } + draftID, ok := flagByName["draft-id"] + if !ok { + t.Fatal("missing --draft-id flag") + } + if !draftID.Required { + t.Error("--draft-id must be Required so cobra rejects missing-flag invocations") + } + if draftID.Type != "string_slice" { + t.Errorf("--draft-id Type = %q, want %q", draftID.Type, "string_slice") + } + stopOnErr, ok := flagByName["stop-on-error"] + if !ok { + t.Fatal("missing --stop-on-error flag") + } + if stopOnErr.Required { + t.Error("--stop-on-error must be optional") + } + if stopOnErr.Type != "bool" { + t.Errorf("--stop-on-error Type = %q, want %q", stopOnErr.Type, "bool") + } +} + +// stubDraftSend registers a stub for POST .../drafts//send with the +// supplied response body. Used to assemble multi-draft test scenarios. +func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]interface{}) *httpmock.Stub { + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts/" + draftID + "/send", + Body: body, + } + reg.Register(stub) + return stub +} + +// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends +// successfully, sent[] is fully populated, failed[] is omitted from the JSON, +// and exit code = 0 (err == nil). +func TestMailDraftSend_AllSuccess(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_1", + "thread_id": "thread_1", + }, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_2", + "thread_id": "thread_2", + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("expected nil err on full success, got %v", err) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 2 { + t.Errorf("total = %v, want 2", data["total"]) + } + if data["success_count"].(float64) != 2 { + t.Errorf("success_count = %v, want 2", data["success_count"]) + } + if data["failure_count"].(float64) != 0 { + t.Errorf("failure_count = %v, want 0", data["failure_count"]) + } + sent, ok := data["sent"].([]interface{}) + if !ok || len(sent) != 2 { + t.Fatalf("sent[] missing or wrong size: %#v", data["sent"]) + } + if _, exists := data["failed"]; exists { + t.Errorf("failed[] should be omitted on full success; got %#v", data["failed"]) + } + first := sent[0].(map[string]interface{}) + if first["draft_id"] != "d1" || first["message_id"] != "msg_1" || first["thread_id"] != "thread_1" { + t.Errorf("first sent entry shape unexpected: %#v", first) + } +} + +// TestMailDraftSend_ProgressWritesToStderr verifies long sends do not look +// hung: per-draft progress is emitted on stderr while stdout remains the +// final machine-readable JSON ledger. +func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) { + f, stdout, stderr, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_1", + }, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 230001, + "msg": "draft not found", + }) + stubDraftSend(reg, "d3", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_3", + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + + progress := stderr.String() + for _, want := range []string{ + "mail +draft-send: [1/3] sending draft d1", + "mail +draft-send: [1/3] sent draft d1 message_id=msg_1", + "mail +draft-send: [2/3] sending draft d2", + "mail +draft-send: [2/3] failed draft d2:", + "mail +draft-send: [3/3] sending draft d3", + "mail +draft-send: [3/3] sent draft d3 message_id=msg_3", + } { + if !strings.Contains(progress, want) { + t.Errorf("stderr missing %q; got %s", want, progress) + } + } + if strings.Contains(stdout.String(), "mail +draft-send:") { + t.Errorf("stdout must not contain progress lines; got %s", stdout.String()) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 { + t.Errorf("unexpected aggregate counts: %#v", data) + } +} + +// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft +// failure does not abort the batch; the remaining drafts are attempted; both +// arrays are populated; and the call returns ExitAPI/"partial_failure". +func TestMailDraftSend_PartialFailure(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + // Non-fatal code (not in the {auth, app_status, config, permission, + // network, 1234013, 1236007, 1236008, 1236009, 1236010, 1236013} + // set) → recoverable. + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 230001, + "msg": "draft not found or already sent", + }) + stubDraftSend(reg, "d3", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_3"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" { + t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } + if data["success_count"].(float64) != 2 { + t.Errorf("success_count = %v, want 2", data["success_count"]) + } + if data["failure_count"].(float64) != 1 { + t.Errorf("failure_count = %v, want 1", data["failure_count"]) + } + failed, ok := data["failed"].([]interface{}) + if !ok || len(failed) != 1 { + t.Fatalf("failed[] missing or wrong size: %#v", data["failed"]) + } + failedEntry := failed[0].(map[string]interface{}) + if failedEntry["draft_id"] != "d2" { + t.Errorf("failed entry draft_id = %v, want d2", failedEntry["draft_id"]) + } + if !strings.Contains(strings.ToLower(failedEntry["error"].(string)), "draft not found") { + t.Errorf("failed entry error should contain server msg, got %q", failedEntry["error"]) + } +} + +// TestMailDraftSend_StopOnError verifies --stop-on-error short-circuits at the +// first recoverable failure. d3 is intentionally NOT stubbed: if the loop +// kept going, the httpmock RoundTripper would return "no stub for POST +// /user_mailboxes/me/drafts/d3/send" and Execute would surface it. +func TestMailDraftSend_StopOnError(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 230001, + "msg": "draft not found", + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + "--stop-on-error", + }, f, stdout) + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 1 { + t.Errorf("success_count = %v, want 1", data["success_count"]) + } + if data["failure_count"].(float64) != 1 { + t.Errorf("failure_count = %v, want 1", data["failure_count"]) + } + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } +} + +// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not +// found) aborts the batch immediately and does NOT populate failed[]; the +// later drafts are not attempted (d2 is intentionally not stubbed — any +// attempt would be observable as a runner failure from the httpmock layer). +func TestMailDraftSend_FatalAborts(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": output.LarkErrMailboxNotFound, + "msg": "mailbox not found", + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected fatal abort error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound { + t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail) + } + // No JSON envelope on stdout because Execute returned early before rt.Out. + if stdout.Len() != 0 { + t.Errorf("expected no JSON output on fatal abort, got %s", stdout.String()) + } +} + +// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error +// after earlier side effects still emits the aggregate stdout ledger before +// returning the fatal stderr error. This lets callers avoid blindly retrying a +// draft that was already sent. +func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": output.LarkErrMailSendQuotaUser, + "msg": "user daily send count exceeded", + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected fatal abort error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser { + t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } + if data["success_count"].(float64) != 1 { + t.Errorf("success_count = %v, want 1", data["success_count"]) + } + if data["failure_count"].(float64) != 1 { + t.Errorf("failure_count = %v, want 1", data["failure_count"]) + } + if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" { + t.Errorf("sent[0].draft_id = %q, want d1", got) + } + if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" { + t.Errorf("failed[0].draft_id = %q, want d2", got) + } +} + +// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response +// carrying the automation_send_disable signal aborts the batch with +// ExitAPI/"automation_send_disabled" and does NOT continue to subsequent +// drafts (d2 intentionally has no stub — any attempt would surface as an +// httpmock "no stub" failure). +func TestMailDraftSend_AutomationDisabled(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_1", + "automation_send_disable": map[string]interface{}{ + "reason": "policy: outbound automation disabled", + }, + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected automation_send_disabled error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" { + t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail) + } + if !strings.Contains(exitErr.Error(), "outbound automation disabled") { + t.Errorf("error message should propagate reason, got %q", exitErr.Error()) + } +} + +// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an +// automation-send policy stop after earlier successful sends still writes the +// batch ledger to stdout before returning the structured fatal error. +func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_2", + "automation_send_disable": map[string]interface{}{ + "reason": "policy: outbound automation disabled", + }, + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected automation_send_disabled error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" { + t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } + if data["success_count"].(float64) != 1 { + t.Errorf("success_count = %v, want 1", data["success_count"]) + } + if data["failure_count"].(float64) != 1 { + t.Errorf("failure_count = %v, want 1", data["failure_count"]) + } + if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" { + t.Errorf("sent[0].draft_id = %q, want d1", got) + } + if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" { + t.Errorf("failed[0].draft_id = %q, want d2", got) + } + if got := gjsonLikeString(t, data, "failed", 0, "error"); !strings.Contains(got, "outbound automation disabled") { + t.Errorf("failed[0].error should contain reason, got %q", got) + } +} + +// TestMailDraftSend_ValidateErrors verifies that input-shape problems are +// caught in the pre-call layers (cobra Required + Validate). No network call +// is registered; the test should fail loudly if any HTTP call is attempted +// (httpmock returns "no stub" in that case). +func TestMailDraftSend_ValidateErrors(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + wantCobra bool // true → cobra-level MarkFlagRequired error path + }{ + { + name: "missing draft-id", + args: []string{"+draft-send", "--yes"}, + wantSub: `required flag(s) "draft-id" not set`, + wantCobra: true, + }, + { + // cobra's StringSlice treats a bare "" as an unset flag, so pass a + // whitespace-only element instead to drive the Validate-callback + // empty-element branch. + name: "whitespace-only value", + args: []string{"+draft-send", "--draft-id", " ", "--yes"}, + wantSub: "--draft-id contains empty value", + }, + { + name: "exceeds cap", + args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--yes"}, + wantSub: "too many drafts", + }, + { + name: "duplicate value", + args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--yes"}, + wantSub: "--draft-id contains duplicate value: d1", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout) + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), c.wantSub) { + t.Errorf("err = %v, want substring %q", err, c.wantSub) + } + }) + } +} + +func TestMailDraftSend_DryRunValidateErrors(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + }{ + { + name: "whitespace-only value", + args: []string{"+draft-send", "--draft-id", " ", "--dry-run"}, + wantSub: "--draft-id contains empty value", + }, + { + name: "exceeds cap", + args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--dry-run"}, + wantSub: "too many drafts", + }, + { + name: "duplicate value", + args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--dry-run"}, + wantSub: "--draft-id contains duplicate value: d1", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout) + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), c.wantSub) { + t.Errorf("err = %v, want substring %q", err, c.wantSub) + } + if stdout.Len() != 0 { + t.Errorf("expected no dry-run output on validation error, got %s", stdout.String()) + } + }) + } +} + +// manyDraftIDs returns a CSV string with n synthesised IDs. Used to drive the +// >MaxBatchSendDrafts validation branch without bloating the test file with a +// hand-written list. +func manyDraftIDs(n int) string { + parts := make([]string, n) + for i := range parts { + parts[i] = "d" + strings.Repeat("x", 1) + intToString(i) + } + return strings.Join(parts, ",") +} + +// intToString avoids the strconv import noise for a tiny test helper. +func intToString(i int) string { + if i == 0 { + return "0" + } + var buf [20]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + return string(buf[pos:]) +} + +// TestMailDraftSend_MissingYes verifies the framework's high-risk-write +// confirmation gate triggers ExitConfirmationRequired (10) when --yes is +// omitted, before Execute is called. +func TestMailDraftSend_MissingYes(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1", + }, f, stdout) + if err == nil { + t.Fatal("expected ExitConfirmationRequired, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitConfirmationRequired { + t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired) + } +} + +// TestMailDraftSend_DryRun verifies --dry-run prints N POST calls in input +// order and does NOT touch the network. +func TestMailDraftSend_DryRun(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", " d1 , d2 ", + "--draft-id", " d3 ", + "--yes", + "--dry-run", + }, f, stdout) + if err != nil { + t.Fatalf("dry-run failed: %v", err) + } + s := stdout.String() + for _, want := range []string{ + `/user_mailboxes/me/drafts/d1/send`, + `/user_mailboxes/me/drafts/d2/send`, + `/user_mailboxes/me/drafts/d3/send`, + `"method"`, + `"POST"`, + } { + if !strings.Contains(s, want) { + t.Errorf("dry-run output missing %q; got %s", want, s) + } + } +} + +// TestMailDraftSend_NormalizesDraftIDs verifies request paths and output use +// trimmed draft IDs rather than preserving CLI whitespace. +func TestMailDraftSend_NormalizesDraftIDs(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_2"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", " d1 , d2 ", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" { + t.Errorf("sent[0].draft_id = %q, want d1", got) + } + if got := gjsonLikeString(t, data, "sent", 1, "draft_id"); got != "d2" { + t.Errorf("sent[1].draft_id = %q, want d2", got) + } +} + +// TestMailDraftSend_DryRunDirectInvocation drives dryRunDraftSend through a +// hand-built RuntimeContext so the dry-run plan can be inspected without the +// full Mount pipeline. Useful for catching path-encoding regressions in +// mailboxPath(). +func TestMailDraftSend_DryRunDirectInvocation(t *testing.T) { + rt := runtimeForMailDraftSendTest(t, map[string]string{ + "mailbox": "alice@example.com", + }, []string{"d1", "d2"}) + api := dryRunDraftSend(context.Background(), rt) + raw, err := json.Marshal(api) + if err != nil { + t.Fatalf("marshal dry-run failed: %v", err) + } + s := string(raw) + for _, want := range []string{ + `/user_mailboxes/alice@example.com/drafts/d1/send`, + `/user_mailboxes/alice@example.com/drafts/d2/send`, + `"method":"POST"`, + } { + if !strings.Contains(s, want) { + t.Errorf("dry-run JSON missing %q; got %s", want, s) + } + } +} + +// runtimeForMailDraftSendTest builds a minimal RuntimeContext with the +draft- +// send flag set so the DryRun callback can be exercised directly. Mirrors +// runtimeForMailDeclineReceiptDryRun. +func runtimeForMailDraftSendTest(t *testing.T, strFlags map[string]string, draftIDs []string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("mailbox", "", "") + cmd.Flags().StringSlice("draft-id", nil, "") + cmd.Flags().Bool("stop-on-error", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("parse flags failed: %v", err) + } + for k, v := range strFlags { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set flag --%s failed: %v", k, err) + } + } + for _, id := range draftIDs { + if err := cmd.Flags().Set("draft-id", id); err != nil { + t.Fatalf("set draft-id failed: %v", err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +// TestMailDraftSend_MailboxFallback verifies that omitting --mailbox falls +// through to "me" via resolveComposeMailboxID, and the output reflects it. +func TestMailDraftSend_MailboxFallback(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["mailbox_id"] != "me" { + t.Errorf("mailbox_id = %v, want me (default)", data["mailbox_id"]) + } +} + +// TestMailDraftSend_RepeatedFlagAndCSV verifies that string_slice supports +// both the repeated-flag form (--draft-id d1 --draft-id d2) and the +// comma-separated form (--draft-id d1,d2) — and mixing both in one invocation. +func TestMailDraftSend_RepeatedFlagAndCSV(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_2"}, + }) + stubDraftSend(reg, "d3", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_3"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--draft-id", "d3", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 3 { + t.Errorf("success_count = %v, want 3", data["success_count"]) + } +} + +// TestIsFatalSendErr is a focused unit test for the classifier. Covers every +// branch documented in the doc comment so future tweaks immediately surface +// mis-categorisation. +func TestIsFatalSendErr(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + { + name: "nil-like / unknown shape → fatal", + err: errors.New("raw network panic surfaced unwrapped"), + want: true, + }, + { + name: "ExitError without Detail → fatal", + err: &output.ExitError{Code: output.ExitInternal}, + want: true, + }, + { + name: "auth → fatal", + err: &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{Type: "auth", Message: "token expired"}, + }, + want: true, + }, + { + name: "app_status → fatal", + err: &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"}, + }, + want: true, + }, + { + name: "config → fatal", + err: &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"}, + }, + want: true, + }, + { + name: "permission → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "permission", Message: "denied"}, + }, + want: true, + }, + { + name: "rate_limit → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit}, + }, + want: true, + }, + { + name: "ExitNetwork → fatal", + err: &output.ExitError{ + Code: output.ExitNetwork, + Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"}, + }, + want: true, + }, + { + name: "wrapped ExitNetwork → fatal", + err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")), + want: true, + }, + { + name: "LarkErrMailboxNotFound → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound}, + }, + want: true, + }, + { + name: "LarkErrMailSendQuotaUser → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser}, + }, + want: true, + }, + { + name: "LarkErrTenantStorageLimit → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit}, + }, + want: true, + }, + { + name: "generic api_error → recoverable", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: 230001}, + }, + want: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := isFatalSendErr(c.err) + if got != c.want { + t.Errorf("isFatalSendErr(%s) = %v, want %v", c.name, got, c.want) + } + }) + } +} + +// TestExtractAutomationDisabledReason verifies all branches of the helper: +// missing key → "", malformed map → generic message, empty/whitespace reason +// → generic message, non-empty reason → trimmed value. +func TestExtractAutomationDisabledReason(t *testing.T) { + cases := []struct { + name string + in map[string]interface{} + want string + }{ + {"missing key", map[string]interface{}{"message_id": "x"}, ""}, + {"non-map value", map[string]interface{}{ + "automation_send_disable": "not a map", + }, "automation send disabled (no reason provided)"}, + {"map but no reason", map[string]interface{}{ + "automation_send_disable": map[string]interface{}{}, + }, "automation send disabled (no reason provided)"}, + {"reason empty", map[string]interface{}{ + "automation_send_disable": map[string]interface{}{"reason": " "}, + }, "automation send disabled (no reason provided)"}, + {"reason populated", map[string]interface{}{ + "automation_send_disable": map[string]interface{}{"reason": " policy block "}, + }, "policy block"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := extractAutomationDisabledReason(c.in) + if got != c.want { + t.Errorf("extractAutomationDisabledReason() = %q, want %q", got, c.want) + } + }) + } +} + +func gjsonLikeString(t *testing.T, data map[string]interface{}, arrayKey string, index int, field string) string { + t.Helper() + items, ok := data[arrayKey].([]interface{}) + if !ok { + t.Fatalf("%s missing or wrong type: %#v", arrayKey, data[arrayKey]) + } + if index >= len(items) { + t.Fatalf("%s[%d] missing; len=%d", arrayKey, index, len(items)) + } + item, ok := items[index].(map[string]interface{}) + if !ok { + t.Fatalf("%s[%d] wrong type: %#v", arrayKey, index, items[index]) + } + value, ok := item[field].(string) + if !ok { + t.Fatalf("%s[%d].%s missing or wrong type: %#v", arrayKey, index, field, item[field]) + } + return value +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 8bd7a7f01..e0d8a9ee7 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -17,6 +17,7 @@ func Shortcuts() []common.Shortcut { MailReplyAll, MailSend, MailDraftCreate, + MailDraftSend, MailDraftEdit, MailForward, MailSendReceipt, diff --git a/tests/cli_e2e/mail/coverage.md b/tests/cli_e2e/mail/coverage.md index f48394441..2910238f2 100644 --- a/tests/cli_e2e/mail/coverage.md +++ b/tests/cli_e2e/mail/coverage.md @@ -1,12 +1,13 @@ # Mail CLI E2E Coverage ## Metrics -- Denominator: 62 leaf commands -- Covered: 13 -- Coverage: 21.0% +- Denominator: 63 leaf commands +- Covered: 14 +- Coverage: 22.2% ## Summary - TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`. +- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`. - TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`. - Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-all’s recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows. @@ -16,6 +17,7 @@ | --- | --- | --- | --- | --- | --- | | ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients | | ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update | +| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape | | ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection | | ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send | | ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies | diff --git a/tests/cli_e2e/mail/mail_draft_send_dryrun_test.go b/tests/cli_e2e/mail/mail_draft_send_dryrun_test.go new file mode 100644 index 000000000..4816f0a25 --- /dev/null +++ b/tests/cli_e2e/mail/mail_draft_send_dryrun_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "strconv" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMail_DraftSendDryRun(t *testing.T) { + setMailDraftSendDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+draft-send", + "--mailbox", "alias@example.com", + "--draft-id", " draft_001, draft_002 ", + "--draft-id", " draft_003 ", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + wantURLs := []string{ + "/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send", + "/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send", + "/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send", + } + assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout) + for i, wantURL := range wantURLs { + idx := strconv.Itoa(i) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout) + assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout) + } +} + +func TestMail_DraftSendDryRunValidation(t *testing.T) { + setMailDraftSendDryRunEnv(t) + + tests := []struct { + name string + args []string + wantMsg string + }{ + { + name: "reject whitespace draft id", + args: []string{ + "mail", "+draft-send", + "--draft-id", " ", + "--dry-run", + }, + wantMsg: "--draft-id contains empty value", + }, + { + name: "reject too many draft ids", + args: []string{ + "mail", "+draft-send", + "--draft-id", manyDraftIDsForE2E(51), + "--dry-run", + }, + wantMsg: "too many drafts", + }, + { + name: "reject duplicate draft id", + args: []string{ + "mail", "+draft-send", + "--draft-id", "draft_001,draft_002,draft_001", + "--dry-run", + }, + wantMsg: "--draft-id contains duplicate value: draft_001", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + output := result.Stdout + result.Stderr + assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + }) + } +} + +func setMailDraftSendDryRunEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} + +func manyDraftIDsForE2E(n int) string { + ids := make([]byte, 0, n*4) + for i := 0; i < n; i++ { + if i > 0 { + ids = append(ids, ',') + } + ids = append(ids, 'd') + ids = strconv.AppendInt(ids, int64(i), 10) + } + return string(ids) +} diff --git a/tests/cli_e2e/mail/mail_draft_send_workflow_test.go b/tests/cli_e2e/mail/mail_draft_send_workflow_test.go new file mode 100644 index 000000000..11e971b03 --- /dev/null +++ b/tests/cli_e2e/mail/mail_draft_send_workflow_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMail_DraftSendWorkflowAsUser(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + clie2e.SkipWithoutUserToken(t) + + const mailboxID = "me" + suffix := clie2e.GenerateSuffix() + subject := "lark-cli-e2e-mail-draft-send-" + suffix + body := "draft-send workflow body " + suffix + + var primaryEmail string + var draftID string + var draftSent bool + var sentMessageID string + var inboxMessageID string + + parentT.Cleanup(func() { + if draftID != "" && !draftSent { + cleanupCtx, cancel := clie2e.CleanupContext() + defer cancel() + + result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"mail", "user_mailbox.drafts", "delete"}, + DefaultAs: "user", + Params: map[string]any{ + "user_mailbox_id": mailboxID, + "draft_id": draftID, + }, + Yes: true, + }) + clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err) + } + + var messageIDs []string + if sentMessageID != "" { + messageIDs = append(messageIDs, sentMessageID) + } + if inboxMessageID != "" && inboxMessageID != sentMessageID { + messageIDs = append(messageIDs, inboxMessageID) + } + if len(messageIDs) == 0 { + return + } + + cleanupCtx, cancel := clie2e.CleanupContext() + defer cancel() + + result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"mail", "user_mailbox.messages", "batch_trash"}, + DefaultAs: "user", + Params: map[string]any{"user_mailbox_id": mailboxID}, + Data: map[string]any{"message_ids": messageIDs}, + }) + clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err) + }) + + t.Run("get mailbox profile as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"mail", "user_mailboxes", "profile"}, + DefaultAs: "user", + Params: map[string]any{"user_mailbox_id": mailboxID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String() + require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout) + }) + + t.Run("create self-addressed draft as user", func(t *testing.T) { + require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+draft-create", + "--to", primaryEmail, + "--subject", subject, + "--body", body, + "--plain-text", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + draftID = gjson.Get(result.Stdout, "data.draft_id").String() + require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout) + }) + + t.Run("send draft with shortcut as user", func(t *testing.T) { + require.NotEmpty(t, draftID, "draft should be created before +draft-send") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+draft-send", + "--mailbox", mailboxID, + "--draft-id", draftID, + }, + DefaultAs: "user", + Yes: true, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout) + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout) + assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout) + assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout) + + sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String() + require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout) + draftSent = true + }) + + t.Run("find self-received message for cleanup", func(t *testing.T) { + require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup") + + for attempt := 0; attempt < 12; attempt++ { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+triage", + "--mailbox", mailboxID, + "--query", subject, + "--max", "10", + "--format", "data", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + for _, item := range gjson.Get(result.Stdout, "messages").Array() { + if item.Get("subject").String() != subject { + continue + } + messageID := item.Get("message_id").String() + if messageID != "" && messageID != sentMessageID { + inboxMessageID = messageID + return + } + } + time.Sleep(2 * time.Second) + } + }) +} From 36ff632a138b307cdef82cec61b9e8fbcbad74c8 Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Wed, 27 May 2026 19:51:59 +0800 Subject: [PATCH 20/62] fix(apps): update miaoda scopes after platform consolidation (#1127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 妙搭/spark consolidated the apps domain onto spark:app:read / spark:app:write. The standalone spark:app:publish and spark:app.access_scope:* scopes are retired. - +html-publish: spark:app:publish -> spark:app:write - +access-scope-get: spark:app.access_scope:read -> spark:app:read - +access-scope-set: spark:app.access_scope:write -> spark:app:write Verified against the official docs for upload_html_code_and_release, get_app_visibility and update_app_visibility. +create/+update/+list were already correct (spark:app:write / spark:app:read). Co-authored-by: Claude Opus 4.7 (1M context) --- shortcuts/apps/apps_access_scope_get.go | 2 +- shortcuts/apps/apps_access_scope_set.go | 2 +- shortcuts/apps/apps_html_publish.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shortcuts/apps/apps_access_scope_get.go b/shortcuts/apps/apps_access_scope_get.go index 5bb5382c4..df1a8cfe2 100644 --- a/shortcuts/apps/apps_access_scope_get.go +++ b/shortcuts/apps/apps_access_scope_get.go @@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{ Command: "+access-scope-get", Description: "Get Miaoda app access scope configuration", Risk: "read", - Scopes: []string{"spark:app.access_scope:read"}, + Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ diff --git a/shortcuts/apps/apps_access_scope_set.go b/shortcuts/apps/apps_access_scope_set.go index 1d41d1e92..f9f474fae 100644 --- a/shortcuts/apps/apps_access_scope_set.go +++ b/shortcuts/apps/apps_access_scope_set.go @@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{ Command: "+access-scope-set", Description: "Set Miaoda app access scope (specific / public / tenant)", Risk: "write", - Scopes: []string{"spark:app.access_scope:write"}, + Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go index 18ac87520..64cc5ae79 100644 --- a/shortcuts/apps/apps_html_publish.go +++ b/shortcuts/apps/apps_html_publish.go @@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{ Command: "+html-publish", Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)", Risk: "write", - Scopes: []string{"spark:app:publish"}, + Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ From cdae9995419ed1d26a774cc9103bef4c0fe158fc Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Wed, 27 May 2026 20:52:24 +0800 Subject: [PATCH 21/62] chore(release): v1.0.42 (#1137) Change-Id: Id4478295cf364a01b712b7ddcd4a6cbdc264e28d --- CHANGELOG.md | 25 +++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f3c1f28..9ebb3c453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project will be documented in this file. +## [v1.0.42] - 2026-05-27 + +### Features + +- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017) +- **im**: Enrich messages with reactions and output `update_time` (#1095) +- **schema**: Output JSON spec envelope for all API commands (#1048) +- **event**: Support `vc` / `note` / `minute` events (#1113) +- **drive**: Add secure label shortcuts (#985) +- **affordance**: Use description and command in affordance example schema (#1126) + +### Bug Fixes + +- **docs**: Remove unsupported `fetch` text format (#1109) + +### Refactor + +- **auth**: Drop duplicate top-level user fields in `status` (#1128) + +### Documentation + +- **doc**: Document block anchor URLs in `lark-doc` skill (#1120) +- **whiteboard**: Improve SVG/Mermaid instructions (#1097) + ## [v1.0.41] - 2026-05-26 ### Features @@ -886,6 +910,7 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42 [v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41 [v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40 [v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39 diff --git a/package.json b/package.json index ad210d2e1..e6e060600 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.41", + "version": "1.0.42", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" From bbef3cbfb16b51b0cbdb2ff78832d3a6a56ca641 Mon Sep 17 00:00:00 2001 From: bubbmon233 Date: Wed, 27 May 2026 22:23:32 +0800 Subject: [PATCH 22/62] =?UTF-8?q?feat(mail):=20HTML=20lint=20library=20+?= =?UTF-8?q?=20Larksuite-native=20autofix=20+=20lark-mail=20=E2=80=A6=20(#1?= =?UTF-8?q?019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill 为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、 安全性与 Larksuite-native 格式适配。 lint 库(shortcuts/mail/lint/): - 四档分类:pass / native-autofix / warn-autofix / error-strip - 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它 危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error - Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、 灰边引用、Larksuite 蓝链接 - cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于 golden-file 测试与缓存 +lint-html 独立预检 shortcut: - 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果 写入路径内置 lint(6 个 compose shortcut): - +send / +draft-create / +draft-edit / +reply / +reply-all / +forward 在 emlbuilder 之前强制 lint 净化 HTML - 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费; --show-lint-details 显式取证返回 lint_applied[] / original_blocked[] - --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥 预制 HTML 邮件模板(skills/lark-mail/assets/templates/): - 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套 - 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构 lark-mail skill 文档: - references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section + 颜色调色盘 + URL scheme + 官方模板套用流程) - references/lark-mail-lint-html.md:+lint-html 用法 - SKILL.md 顶部 CRITICAL 引导 * fix(mail): remove unused readAttr func and apply gofmt Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go that was flagged by golangci-lint (unused linter). Apply gofmt to linter.go and rules.go which had minor formatting issues. * fix(mail): address compose lint and guidance --- shortcuts/mail/body_file.go | 109 ++ shortcuts/mail/lint/linter.go | 1156 +++++++++++++++++ shortcuts/mail/lint/linter_test.go | 920 +++++++++++++ shortcuts/mail/lint/rules.go | 353 +++++ shortcuts/mail/lint/types.go | 92 ++ shortcuts/mail/mail_draft_create.go | 106 +- shortcuts/mail/mail_draft_create_test.go | 22 +- shortcuts/mail/mail_draft_edit.go | 66 +- shortcuts/mail/mail_forward.go | 37 +- shortcuts/mail/mail_lint_html.go | 170 +++ shortcuts/mail/mail_lint_html_test.go | 274 ++++ shortcuts/mail/mail_lint_writepath.go | 131 ++ shortcuts/mail/mail_lint_writepath_test.go | 719 ++++++++++ shortcuts/mail/mail_reply.go | 50 +- shortcuts/mail/mail_reply_all.go | 46 +- shortcuts/mail/mail_send.go | 57 +- .../mail/mail_shortcut_validation_test.go | 53 + shortcuts/mail/shortcuts.go | 1 + skill-template/domains/mail.md | 51 + skills/lark-mail/SKILL.md | 18 +- .../templates/job-application--resume.html | 33 + .../templates/newsletter--weekly-brief.html | 50 + .../templates/research--market-report.html | 256 ++++ .../templates/weekly--personal-report.html | 43 + .../assets/templates/weekly--team-report.html | 9 + .../references/lark-mail-draft-create.md | 5 +- .../references/lark-mail-draft-edit.md | 28 +- .../lark-mail/references/lark-mail-forward.md | 5 +- skills/lark-mail/references/lark-mail-html.md | 333 +++++ .../references/lark-mail-lint-html.md | 243 ++++ .../references/lark-mail-reply-all.md | 5 +- .../lark-mail/references/lark-mail-reply.md | 5 +- skills/lark-mail/references/lark-mail-send.md | 5 +- 33 files changed, 5356 insertions(+), 95 deletions(-) create mode 100644 shortcuts/mail/body_file.go create mode 100644 shortcuts/mail/lint/linter.go create mode 100644 shortcuts/mail/lint/linter_test.go create mode 100644 shortcuts/mail/lint/rules.go create mode 100644 shortcuts/mail/lint/types.go create mode 100644 shortcuts/mail/mail_lint_html.go create mode 100644 shortcuts/mail/mail_lint_html_test.go create mode 100644 shortcuts/mail/mail_lint_writepath.go create mode 100644 shortcuts/mail/mail_lint_writepath_test.go create mode 100644 skills/lark-mail/assets/templates/job-application--resume.html create mode 100644 skills/lark-mail/assets/templates/newsletter--weekly-brief.html create mode 100644 skills/lark-mail/assets/templates/research--market-report.html create mode 100644 skills/lark-mail/assets/templates/weekly--personal-report.html create mode 100644 skills/lark-mail/assets/templates/weekly--team-report.html create mode 100644 skills/lark-mail/references/lark-mail-html.md create mode 100644 skills/lark-mail/references/lark-mail-lint-html.md diff --git a/shortcuts/mail/body_file.go b/shortcuts/mail/body_file.go new file mode 100644 index 000000000..f15c05ab2 --- /dev/null +++ b/shortcuts/mail/body_file.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "io" + "strings" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// bodyFileFlag is the shared `--body-file` flag declaration reused by every +// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward). +// All six shortcuts honour the same mutual-exclusion contract with `--body` +// and the cwd-subtree path safety rule. The flag is intentionally NOT +// shared with `+lint-html` because that command's description differs +// ("HTML to lint" vs "email body") in a way that is more readable when +// authored per-shortcut. `+draft-edit` does not expose `--body-file` either +// — its body ops flow through `--patch-file` JSON whose `value` field is +// the natural file-based entry point for large bodies. +var bodyFileFlag = common.Flag{ + Name: "body-file", + Desc: "Path (relative, within cwd subtree) to a file containing the email body HTML. Mutually exclusive with --body. Size capped at 32 MB.", + Input: []string{common.File}, +} + +// maxBodyFileSize caps the size of a `--body-file` HTML input. The compose +// path's downstream EML limit is 25 MB (helpers.go MAX_EML_BYTES); we allow a +// bit more headroom here (32 MB) so a body close to the limit still loads +// before the downstream check fires with a clearer error message. The cap +// prevents an `io.ReadAll` from blowing memory on a misdirected gigabyte +// file. +const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB + +// validateBodyFileMutex enforces the `--body` / `--body-file` mutual +// exclusion + cwd-subtree path safety. Compose shortcuts call this in +// their Validate phase so AI / users see a clear error before any work +// runs. Pass the shortcut's RuntimeContext-resolved flag values directly: +// `bodyFlag` is the `--body` value (may be empty), `bodyFile` is the +// trimmed `--body-file` value, and `validatePath` is the +// runtime.ValidatePath bound function used to enforce the relative-path +// rule (cwd-subtree only; no absolute / `..` traversal). +// +// Returns an ErrValidation error when either invariant is violated, nil +// otherwise. The "exactly one of {--body, --body-file}" check is +// shortcut-specific (some shortcuts allow neither, e.g. `+forward` with +// no explicit body) and is therefore left to the caller. +func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error { + bodyEmpty := strings.TrimSpace(bodyFlag) == "" + if !bodyEmpty && bodyFile != "" { + return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one") + } + if bodyFile != "" { + if err := validatePath(bodyFile); err != nil { + return output.ErrValidation("--body-file: %v", err) + } + } + return nil +} + +// resolveBodyFromFlags returns the body content from --body or --body-file. +// Validate has already enforced mutual exclusion via validateBodyFileMutex, +// so exactly one is set (or neither when a template / parent message +// supplies the body). Returns ("", nil) when neither flag is set so +// downstream code can decide whether the empty body is allowed. +func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) { + if body := runtime.Str("body"); strings.TrimSpace(body) != "" { + return body, nil + } + path := strings.TrimSpace(runtime.Str("body-file")) + if path == "" { + return "", nil + } + return readBodyFile(runtime.FileIO(), path) +} + +func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error { + if !hasTemplate && strings.TrimSpace(body) == "" { + return output.ErrValidation(message) + } + return nil +} + +// readBodyFile loads --body-file content with a size cap. Returns an +// ErrValidation error if the file exceeds maxBodyFileSize or any IO error +// occurs. The size check uses io.LimitReader(maxBodyFileSize+1) so any +// over-cap byte is observable without reading the whole file. +// +// Callers MUST have run runtime.ValidatePath(path) on `path` first — the +// helper only opens the file via the supplied FileIO and does not repeat +// the cwd-subtree safety check. +func readBodyFile(fio fileio.FileIO, path string) (string, error) { + f, err := fio.Open(path) + if err != nil { + return "", output.ErrValidation("open --body-file %s: %v", path, err) + } + defer f.Close() + buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1)) + if err != nil { + return "", output.ErrValidation("read --body-file %s: %v", path, err) + } + if len(buf) > maxBodyFileSize { + return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024) + } + return string(buf), nil +} diff --git a/shortcuts/mail/lint/linter.go b/shortcuts/mail/lint/linter.go new file mode 100644 index 000000000..d0286a4f0 --- /dev/null +++ b/shortcuts/mail/lint/linter.go @@ -0,0 +1,1156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lint + +import ( + "bytes" + "fmt" + "hash/fnv" + "strings" + + xhtml "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// MaxExcerptBytes caps the raw-HTML excerpt embedded in a Finding.Excerpt so +// a single offending tag with megabyte content can't bloat the envelope JSON. +// Lint operates on bytes only, but the excerpt representation must not be +// size-amplifying. +const MaxExcerptBytes = 200 + +// Run lints the given HTML body and returns a structured Report. +// Report.CleanedHTML contains the rewritten HTML (warnings rewritten + errors +// deleted) — the autofix is unconditional. +// +// IMPORTANT: when the input is empty or plain-text (no HTML markup detected +// by the cli's existing `bodyIsHTML` heuristic), callers should short-circuit +// with EmptyReport(html) instead of paying the parse cost. Run still handles +// this gracefully — html.Parse on plain text wraps the input in +// ..., and the lib's pass-through +// rendering will reproduce the original text — but the round-trip is wasteful +// and produces no findings. +func Run(html string, opts Options) Report { + if html == "" { + return EmptyReport("") + } + + rep := Report{ + Applied: []Finding{}, + Blocked: []Finding{}, + } + + // We use html.ParseFragment so users authoring fragment-style snippets + // (the canonical compose-5 input shape — `

...
` rather than a + // full document) don't get implicit wrappers + // re-rendered. The "body" insertion mode matches what html.Parse would + // have done internally for a fragment but skips the structural wrappers + // at render time. + bodyContext := &xhtml.Node{Type: xhtml.ElementNode, DataAtom: atom.Body, Data: "body"} + nodes, err := xhtml.ParseFragment(strings.NewReader(html), bodyContext) + if err != nil { + // Parser failure is exceptional (the parser is permissive by design); + // fall back to the original input so we don't lose user content. + return EmptyReport(html) + } + + // Wrap fragment nodes in a synthetic root so the recursive walker has a + // uniform parent pointer to mutate. + root := &xhtml.Node{Type: xhtml.DocumentNode} + for _, n := range nodes { + root.AppendChild(n) + } + + walk(root, &rep) + // nativeCtx tracks per-Run() state so positional ids (e.g. data-ol-id) + // are deterministic across multiple Run() calls on the same input — + // keying off the document-traversal order rather than heap pointers, + // so cleaned_html is byte-stable and amenable to golden-file tests / CI + // diff / cache-key reuse. + nctx := &nativeCtx{olIDs: map[*xhtml.Node]string{}} + applyFeishuNativeStyles(root, &rep, nctx) + + rep.HasErrorFindings = len(rep.Blocked) > 0 + rep.HasWarningFindings = len(rep.Applied) > 0 + rep.CleanedHTML = renderFragment(root) + + return rep +} + +// walk visits every element node under parent, applying tag/attr/style +// classification. Children are iterated via the next-sibling pointer because +// we mutate the tree in place (replace / remove nodes). +// +// The walker is iterative-style via explicit recursion because the html +// parser's typical nesting depth (≤ 256 by default) is well below Go's +// goroutine stack limit; the existing draft package's plainTextFromHTML +// (mail/draft/htmltext.go) similarly recurses for the same reason. +func walk(parent *xhtml.Node, rep *Report) { + child := parent.FirstChild + for child != nil { + next := child.NextSibling + if child.Type == xhtml.ElementNode { + processElement(parent, child, rep) + } + // child may have been removed/replaced by processElement; recurse + // only if it still has the original parent (i.e. wasn't deleted). + // The html parser sets Parent on every node, so a removed-then- + // reattached node still recurses correctly via its new Parent. + if child.Parent != nil { + walk(child, rep) + } + child = next + } +} + +// processElement applies the element-level classification cascade: +// 1. tag → allow / warn-rewrite / error-delete +// 2. attributes → on*-handlers, URL-bearing attrs (scheme allow-list), +// style attribute (CSS property allow-list) +func processElement(parent, n *xhtml.Node, rep *Report) { + tagName := strings.ToLower(n.Data) + kind, ruleID := classifyTag(tagName) + + switch kind { + case "error": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForBlockedTag(tagName), + }) + // Always remove blocked tags — the writing-path safety floor has no + // opt-out; `--no-lint` is not provided. + parent.RemoveChild(n) + return + + case "warn": + // Always rewrite (e.g. ) and surface the finding. + rep.Applied = append(rep.Applied, Finding{ + RuleID: ruleID, + Severity: SeverityWarning, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForWarnTag(tagName), + }) + rewriteWarnTag(n, tagName) + // Recurse into the rewritten node by falling through; the rewrite + // preserved children as-is. + // fall through to attribute scan + case "allow": + // no-op + } + + // Attribute scan: build a new attribute slice, dropping/sanitising as we + // go and surfacing findings. + if len(n.Attr) > 0 { + processAttributes(n, rep) + } +} + +// processAttributes walks the attribute list and: +// - drops on*-handlers (always; surfaced as error) +// - drops URL-bearing attrs whose value uses a forbidden scheme +// - filters the `style` attribute property-by-property against the allow-list +// +// Other attributes pass through unchanged. The cli's existing +// `validateInlineCIDs` (helpers.go:2226) handles `cid:`-specific checks; +// the lint must not duplicate that responsibility. +func processAttributes(n *xhtml.Node, rep *Report) { + keep := n.Attr[:0] + for _, attr := range n.Attr { + name := strings.ToLower(attr.Key) + + // 1. on*-handlers → always drop, error-tier. + if isEventHandlerAttr(name) { + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: RuleAttrEventHandlerBlocked, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "Removed event handler attribute (on*)", + }) + continue + } + + // 2. URL-bearing attrs → check scheme allow-list. + if urlAttributes[name] { + kind, ruleID := classifyURLValue(attr.Val) + switch kind { + case "error": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "Removed dangerous URL scheme (allowed: http/https/mailto/cid/data:image/*)", + }) + continue + case "warn": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "Removed URL with unrecognised scheme (allowed: http/https/mailto/cid/data:image/*)", + }) + // Always drop the attribute — writing-path safety floor (the + // URL would not render correctly anyway). + continue + } + } + + // 3. `style` attribute → property-by-property allow-list. + if name == "style" { + cleaned, dropped := sanitiseStyleAttr(attr.Val) + for _, prop := range dropped { + rep.Applied = append(rep.Applied, Finding{ + RuleID: RuleStylePropertyDropped, + Severity: SeverityWarning, + TagOrAttr: "style." + prop, + Excerpt: truncateExcerpt(prop), + Hint: "Removed CSS property not in allowlist (see references/lark-mail-html.md)", + }) + } + if len(dropped) == 0 { + // Byte-stable when no property was dropped: leave the + // attribute exactly as authored so lint round-trips are + // idempotent on clean input. + keep = append(keep, attr) + continue + } + if cleaned == "" { + // All properties dropped — remove the attribute entirely. + continue + } + attr.Val = cleaned + keep = append(keep, attr) + continue + } + + // 4. Pass-through. + keep = append(keep, attr) + } + n.Attr = keep +} + +// rewriteWarnTag replaces a warning-tier tag with its Feishu-native +// equivalent in place: with color/face/size +// distilled into inline style;
; +// / (text-only, animation discarded — collapsing +// to a span keeps the children but drops the deprecated animation effect). +func rewriteWarnTag(n *xhtml.Node, tagName string) { + switch tagName { + case "font": + // Distill . + var styles []string + var keepAttrs []xhtml.Attribute + for _, attr := range n.Attr { + switch strings.ToLower(attr.Key) { + case "color": + if v := strings.TrimSpace(attr.Val); v != "" { + styles = append(styles, "color:"+v) + } + case "face": + if v := strings.TrimSpace(attr.Val); v != "" { + styles = append(styles, "font-family:"+v) + } + case "size": + if v := mapFontSize(attr.Val); v != "" { + styles = append(styles, "font-size:"+v) + } + default: + keepAttrs = append(keepAttrs, attr) + } + } + // Merge any existing style attribute already present on the + // (rare but possible). + if len(styles) > 0 { + merged := strings.Join(styles, ";") + styleIdx := -1 + for i, attr := range keepAttrs { + if strings.ToLower(attr.Key) == "style" { + styleIdx = i + break + } + } + if styleIdx >= 0 { + existing := strings.TrimRight(keepAttrs[styleIdx].Val, "; ") + if existing != "" { + merged = existing + ";" + merged + } + keepAttrs[styleIdx].Val = merged + } else { + keepAttrs = append(keepAttrs, xhtml.Attribute{Key: "style", Val: merged}) + } + } + n.Data = "span" + n.DataAtom = atom.Span + n.Attr = keepAttrs + + case "center": + //
. Existing style attr + // (if any) is merged with text-align prepended. + styleIdx := -1 + for i, attr := range n.Attr { + if strings.ToLower(attr.Key) == "style" { + styleIdx = i + break + } + } + newStyle := "text-align:center" + if styleIdx >= 0 { + existing := strings.TrimRight(n.Attr[styleIdx].Val, "; ") + if existing != "" { + newStyle = newStyle + ";" + existing + } + n.Attr[styleIdx].Val = newStyle + } else { + n.Attr = append(n.Attr, xhtml.Attribute{Key: "style", Val: newStyle}) + } + n.Data = "div" + n.DataAtom = atom.Div + + case "marquee", "blink": + // Both deprecated; collapse to so children survive. + n.Data = "span" + n.DataAtom = atom.Span + // Strip marquee-specific attributes (direction, scrollamount, ...) + // so the rewritten span is plain. + var keepAttrs []xhtml.Attribute + for _, attr := range n.Attr { + if strings.ToLower(attr.Key) == "style" || strings.ToLower(attr.Key) == "class" || strings.ToLower(attr.Key) == "id" { + keepAttrs = append(keepAttrs, attr) + } + } + n.Attr = keepAttrs + } +} + +// mapFontSize maps the legacy values (1..7) to a CSS px +// equivalent, matching the mapping used by Feishu mail-editor's renderer. +// Out-of-range values fall through to the empty string so the property is +// dropped (better than emitting an arbitrary value). +func mapFontSize(raw string) string { + switch strings.TrimSpace(raw) { + case "1": + return "10px" + case "2": + return "13px" + case "3": + return "16px" + case "4": + return "18px" + case "5": + return "24px" + case "6": + return "32px" + case "7": + return "48px" + default: + return "" + } +} + +// sanitiseStyleAttr filters a `style="prop1:val; prop2:val"` declaration +// against the property allow-list. Returns the cleaned style text (joined +// with "; " separators) and a slice of dropped property names (lower-case) +// so the caller can surface STYLE_PROPERTY_DROPPED findings. +// +// NOTE: We do NOT validate property values — only property names. The style +// attribute is filtered by CSS property allow-list; value-level validation +// (e.g. URL safety inside `background-image: url(...)`) is delegated to the +// urlAttributes path because such values typically appear in `src` / `href` +// attrs in compose-5 templates. Users authoring `background-image: url(http:...)` +// in inline style will see the property pass — the URL inside is not a +// security concern at the inline-style level since URL fetching from style +// is restricted by the rendering layer's CSP regardless. +func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) { + if strings.TrimSpace(raw) == "" { + return "", nil + } + parts := strings.Split(raw, ";") + keep := make([]string, 0, len(parts)) + for _, part := range parts { + decl := strings.TrimSpace(part) + if decl == "" { + continue + } + colon := strings.IndexByte(decl, ':') + if colon < 0 { + // Malformed declaration; drop and surface as a finding so the + // user notices. + dropped = append(dropped, decl) + continue + } + name := strings.ToLower(strings.TrimSpace(decl[:colon])) + if !classifyStyleProperty(name) { + dropped = append(dropped, name) + continue + } + keep = append(keep, decl) + } + cleaned = strings.Join(keep, "; ") + return cleaned, dropped +} + +// hintForBlockedTag returns a hint for an error-blocked tag (matching +// the `output.ErrWithHint` convention used elsewhere in the cli). +func hintForBlockedTag(tag string) string { + switch tag { + case "script": + return "Removed whole tag (XSS risk)" + case "iframe", "object", "embed": + return "Removed whole tag (external embeds not allowed; use or a body link for rich media)" + case "form", "input", "select", "option", "button": + return "Removed whole tag (forms not allowed in email body)" + case "link": + return "Removed (external CSS / resources not allowed)" + case "meta": + return "Removed (viewport / refresh declarations not allowed)" + case "base": + return "Removed (URL base rewrites not allowed)" + default: + return "Removed whole tag (tag not allowed)" + } +} + +// hintForWarnTag returns a hint for a warning-tier tag. +func hintForWarnTag(tag string) string { + switch tag { + case "font": + return "Rewritten as (modern HTML expresses size / color via inline style)" + case "center": + return "Rewritten as
(deprecated
tag)" + case "marquee", "blink": + return "Rewritten as (animations not supported; text preserved)" + default: + return "Rewritten in modern HTML shape" + } +} + +// excerptOf renders the offending node's open-tag header into a short string +// suitable for surfacing in a Finding.Excerpt. We render only the tag header +// (not the full subtree) so a single offending

after

`, Options{}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleTagScriptBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked) + } + if strings.Contains(rep.CleanedHTML, " content should be deleted, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_BlockedTagsRemoved iterates all error-tier tags. +func TestRun_BlockedTagsRemoved(t *testing.T) { + cases := map[string]string{ + ``: RuleTagIframeBlocked, + ``: RuleTagObjectBlocked, + ``: RuleTagEmbedBlocked, + `
`: RuleTagFormBlocked, + ``: RuleTagLinkBlocked, + ``: RuleTagMetaBlocked, + ``: RuleTagBaseBlocked, + } + for input, wantRule := range cases { + t.Run(input[:min(len(input), 30)], func(t *testing.T) { + rep := Run(input, Options{}) + found := false + for _, f := range rep.Blocked { + if f.RuleID == wantRule { + found = true + break + } + } + if !found { + t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked) + } + }) + } +} + +// TestRun_EventHandlerAttrBlocked verifies on*-handlers (onclick etc.) are +// stripped — they are an event-handler injection vector. +func TestRun_EventHandlerAttrBlocked(t *testing.T) { + rep := Run(`

x

`, Options{}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked) + } + if strings.Contains(rep.CleanedHTML, "onclick") { + t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, `id="ok"`) { + t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors. +func TestRun_OnErrorAttrBlocked(t *testing.T) { + rep := Run(``, Options{}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" { + hasErr = true + } + } + if !hasErr { + t.Errorf("onerror should fire, got %+v", rep.Blocked) + } +} + +// ===================================================================== +// URL scheme allow-list. +// ===================================================================== + +// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped. +func TestRun_JavaScriptURLBlocked(t *testing.T) { + rep := Run(`click`, Options{}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + hasErr = true + } + } + if !hasErr { + t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked) + } + if strings.Contains(rep.CleanedHTML, "javascript:") { + t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_VBScriptURLBlocked verifies vbscript: is rejected. +func TestRun_VBScriptURLBlocked(t *testing.T) { + rep := Run(`x`, Options{}) + if len(rep.Blocked) == 0 { + t.Errorf("expected vbscript: to be blocked, got 0 findings") + } +} + +// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected +// (only data:image/* is allowed). +func TestRun_DataNonImageURLBlocked(t *testing.T) { + rep := Run(``, Options{}) + if len(rep.Blocked) == 0 { + t.Errorf("expected data:text/html to be blocked") + } +} + +// TestRun_DataImageAllowed verifies data:image/png passes. +func TestRun_DataImageAllowed(t *testing.T) { + rep := Run(``, Options{}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + t.Errorf("data:image/* should pass, got %+v", f) + } + } +} + +// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass. +func TestRun_RelativeURLAllowed(t *testing.T) { + rep := Run(`x`, Options{}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked { + t.Errorf("relative URL should pass, got %+v", f) + } + } +} + +// ===================================================================== +// Style property allow-list. +// ===================================================================== + +// TestRun_StylePropertyDropped verifies non-allow-list properties drop. +func TestRun_StylePropertyDropped(t *testing.T) { + rep := Run(`

x

`, Options{}) + dropped := []string{} + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + dropped = append(dropped, f.TagOrAttr) + } + } + if !sliceContains(dropped, "style.position") { + t.Errorf("expected position to be dropped, got %v", dropped) + } + if !sliceContains(dropped, "style.z-index") { + t.Errorf("expected z-index to be dropped, got %v", dropped) + } + if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") { + t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule. +func TestRun_StyleBorderPrefixAllowed(t *testing.T) { + rep := Run(`

x

`, Options{}) + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + t.Errorf("border-* should pass, got %+v", f) + } + } +} + +// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent +// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner +//
    (mail-editor's own native nested-list shape), the Feishu-list autofix +// must NOT clobber it by appending `margin-left:0`. ensureInlineStyleProps +// is supposed to skip props the user already declared, but earlier +// hasInlineStyleProp was only matching longhand `margin-left:` literally +// and missed the shorthand form, causing 24px indents to be reset to 0. +func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) { + in := `
    • indented
    ` + rep := Run(in, Options{}) + cleaned := rep.CleanedHTML + // Extract just the
      opening tag's style attr (li has its own + // independent margin-left:0 longhand which is correct — list indent + // belongs on the container, not the item). + ulOpen := cleaned + if i := strings.Index(ulOpen, ">"); i >= 0 { + ulOpen = ulOpen[:i] + } + if !strings.Contains(ulOpen, "margin:0px 0px 0px 24px") { + t.Errorf("shorthand margin with 24px left should survive on
        , ulOpen=%q", ulOpen) + } + // The bug signature: extra `margin-left:` appended after the shorthand + // on the
          element itself (CSS rule says the later one wins, so any + // margin-left:0 after the shorthand resets the indent to 0). + if strings.Contains(ulOpen, "margin-left") { + t.Errorf("autofix must not append margin-left longhand onto
            when shorthand already declares it, ulOpen=%q", ulOpen) + } +} + +// TestRun_BlockquoteShorthandBorderPreserved verifies the blockquote native +// autofix does not override a user-authored border shorthand by appending +// border-left. CSS applies the later longhand over the earlier shorthand, so +// adding border-left here would replace the user's left border. +func TestRun_BlockquoteShorthandBorderPreserved(t *testing.T) { + rep := Run(`
            quoted
            `, Options{}) + cleaned := rep.CleanedHTML + if !strings.Contains(cleaned, `border:1px solid red`) { + t.Fatalf("user-authored border shorthand should survive, cleaned=%q", cleaned) + } + if strings.Contains(cleaned, `border-left:`) { + t.Fatalf("autofix must not append border-left when border shorthand already declares it, cleaned=%q", cleaned) + } + if !strings.Contains(cleaned, `color:rgb(100,106,115)`) { + t.Fatalf("blockquote native autofix should still add missing non-border style props, cleaned=%q", cleaned) + } +} + +func TestRun_BlockquoteNativeContentWrapper(t *testing.T) { + rep := Run(`
            quoted
            `, Options{}) + cleaned := rep.CleanedHTML + for _, want := range []string{ + `class="lark-mail-doc-quote"`, + `border-left:2px solid rgb(187,191,196)`, + `
            quoted
            `, + } { + if !strings.Contains(cleaned, want) { + t.Fatalf("cleaned blockquote missing %q, cleaned=%q", want, cleaned) + } + } +} + +func TestRun_BlockquoteNativeContentWrapperIdempotent(t *testing.T) { + in := `
            quoted
            ` + rep := Run(in, Options{}) + if strings.Count(rep.CleanedHTML, `padding-left:12px`) != 1 { + t.Fatalf("native-shaped blockquote should not get nested content wrappers, cleaned=%q", rep.CleanedHTML) + } +} + +func TestRun_ParagraphRewritePreservesDirAndFontSize(t *testing.T) { + rep := Run(`

            hello

            `, Options{}) + cleaned := rep.CleanedHTML + if !strings.Contains(cleaned, `style="font-size:20px;margin-top:4px;margin-bottom:4px;line-height:1.6" dir="rtl"`) { + t.Fatalf("outer paragraph wrapper should preserve author font-size and dir, cleaned=%q", cleaned) + } + if !strings.Contains(cleaned, `
            hello
            `) { + t.Fatalf("inner paragraph wrapper should inherit author dir and omit default font-size, cleaned=%q", cleaned) + } + if strings.Contains(cleaned, `font-size:14px`) { + t.Fatalf("inner paragraph wrapper must not force default font-size over author value, cleaned=%q", cleaned) + } + if strings.Contains(cleaned, `dir="auto"`) { + t.Fatalf("inner paragraph wrapper must not force dir=auto over author value, cleaned=%q", cleaned) + } +} + +// ===================================================================== +// CleanedHTML output / contract guarantees. +// ===================================================================== + +// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty +// slices when nothing is found (the JSON envelope contract requires `[]`, +// not `null`). +func TestRun_EmptyArraysAlwaysPresent(t *testing.T) { + // Use
            instead of

            to avoid the Feishu-native paragraph + // rewrite autofix, which would surface a finding even on otherwise + // clean input. + rep := Run(`

            nothing here
            `, Options{}) + if rep.Applied == nil || rep.Blocked == nil { + t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked) + } + if len(rep.Applied) != 0 || len(rep.Blocked) != 0 { + t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked)) + } +} + +// TestEmptyReport_HasContractFields covers the helper used by compose 5's +// plain-text branch. +func TestEmptyReport_HasContractFields(t *testing.T) { + rep := EmptyReport(`plain text`) + if rep.Applied == nil { + t.Error("Applied must be non-nil") + } + if rep.Blocked == nil { + t.Error("Blocked must be non-nil") + } + if rep.CleanedHTML != "plain text" { + t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text") + } +} + +// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through +// the parser doesn't accidentally lose user content. +func TestRun_CleanedHTMLPreservesStructure(t *testing.T) { + html := `

            title

            body bold end

            • a
            • b
            ` + rep := Run(html, Options{}) + if len(rep.Blocked) != 0 { + t.Fatalf("unexpected blocked: %+v", rep.Blocked) + } + // Feishu-native autofix expected to fire on

            ,

              ,
            • — content + // must still survive untouched even though structure is augmented. + for _, want := range []string{"line-height:1.6", "

              ", "title", "", "bold", ""} { + if !strings.Contains(rep.CleanedHTML, want) { + t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML) + } + } +} + +// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input. +func TestRun_EmptyInput(t *testing.T) { + rep := Run("", Options{}) + if rep.CleanedHTML != "" { + t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML) + } + if len(rep.Applied) != 0 || len(rep.Blocked) != 0 { + t.Errorf("empty input must produce empty findings") + } +} + +// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings. +func TestRun_HasErrorFindingsFlag(t *testing.T) { + rep := Run(``, Options{}) + if !rep.HasErrorFindings { + t.Error("expected HasErrorFindings=true") + } + clean := Run(`

              safe

              `, Options{}) + if clean.HasErrorFindings { + t.Error("expected HasErrorFindings=false on clean HTML") + } +} + +// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings. +func TestRun_HasWarningFindingsFlag(t *testing.T) { + rep := Run(`x`, Options{}) + if !rep.HasWarningFindings { + t.Error("expected HasWarningFindings=true") + } +} + +// ===================================================================== +// Excerpt cap. +// ===================================================================== + +// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap. +func TestTruncateExcerpt_RespectsCap(t *testing.T) { + long := strings.Repeat("x", MaxExcerptBytes+50) + got := truncateExcerpt(long) + if len(got) > MaxExcerptBytes { + t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes) + } + if !strings.HasSuffix(got, " ...") { + t.Errorf("expected truncation suffix, got %q", got[len(got)-10:]) + } +} + +// TestRun_ExcerptCappedForLargeOffender verifies large blocked content +// produces a short excerpt (envelope size protection). +func TestRun_ExcerptCappedForLargeOffender(t *testing.T) { + bigAttr := strings.Repeat("a", MaxExcerptBytes*2) + rep := Run(`x`, Options{}) + if len(rep.Blocked) == 0 { + t.Fatal("expected blocked finding") + } + for _, f := range rep.Blocked { + if len(f.Excerpt) > MaxExcerptBytes { + t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes) + } + } +} + +// ===================================================================== +// Helpers. +// ===================================================================== + +func sliceContains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ===================================================================== +// Additional coverage for edge cases and exhaustive value mapping. +// ===================================================================== + +// TestMapFontSize_ExhaustiveSpan covers every mapping +// + invalid values fall through to "" so the property is dropped. +func TestMapFontSize_ExhaustiveSpan(t *testing.T) { + cases := map[string]string{ + "1": "10px", + "2": "13px", + "3": "16px", + "4": "18px", + "5": "24px", + "6": "32px", + "7": "48px", + "": "", + "8": "", + "abc": "", + "3.5": "", + " 3 ": "16px", + } + for raw, want := range cases { + got := mapFontSize(raw) + if got != want { + t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want) + } + } +} + +// TestRun_FontTagWithFaceMappedToFontFamily ensures → +// font-family inline style. +func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) { + rep := Run(`x`, Options{}) + if !strings.Contains(rep.CleanedHTML, "font-family:Arial") { + t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an +// existing style attribute on the same element. +func TestRun_FontTagWithExistingStyleMerged(t *testing.T) { + rep := Run(`x`, Options{}) + if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_CenterTagWithExistingStyleMerged ensures
              's style merge. +func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) { + rep := Run(`
              x
              `, Options{}) + if !strings.Contains(rep.CleanedHTML, "text-align:center") { + t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id. +func TestRun_MarqueeRetainsClassAndID(t *testing.T) { + rep := Run(`y`, Options{}) + if !strings.Contains(rep.CleanedHTML, `class="cls"`) { + t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML) + } + if strings.Contains(rep.CleanedHTML, `direction`) { + t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_UnknownSchemeBlocked verifies an unknown URL scheme produces a +// blocked (error) finding and the attribute is dropped. +func TestRun_UnknownSchemeBlocked(t *testing.T) { + rep := Run(`x`, Options{}) + gotBlocked := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrUnsafeSchemeBlocked { + gotBlocked = true + } + } + if !gotBlocked { + t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED in Blocked, got blocked=%+v applied=%+v", rep.Blocked, rep.Applied) + } + if strings.Contains(rep.CleanedHTML, "webcal:") { + t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..." +// is still caught after control-byte stripping in classifyURLValue. +func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) { + rep := Run("x", Options{}) + gotErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + gotErr = true + } + } + if !gotErr { + t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked) + } +} + +// TestRun_FileSchemeBlocked verifies file: URLs are rejected. +func TestRun_FileSchemeBlocked(t *testing.T) { + rep := Run(`x`, Options{}) + if len(rep.Blocked) == 0 { + t.Error("expected file: to be blocked") + } +} + +// TestRun_StyleMalformedDeclarationDropped verifies a property without a +// colon delimiter is treated as malformed and dropped. +func TestRun_StyleMalformedDeclarationDropped(t *testing.T) { + rep := Run(`

              x

              `, Options{}) + gotMalformed := false + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" { + gotMalformed = true + } + } + if !gotMalformed { + t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied) + } + if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style +// attribute is removed entirely when every property is invalid. +func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) { + // Use
              to avoid the Feishu-native paragraph autofix, which adds + // a fresh style attribute on the rewritten outer wrapper. + rep := Run(`
              x
              `, Options{}) + if strings.Contains(rep.CleanedHTML, "style=") { + t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes. +func TestRun_StyleEmptyValuePassThrough(t *testing.T) { + // Use
              to avoid the Feishu-native paragraph autofix. + rep := Run(`
              x
              `, Options{}) + if len(rep.Applied) != 0 { + t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied) + } +} + +// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a +// non-empty hint (consumer contract). +func TestRun_HintsForAllBlockedTags(t *testing.T) { + cases := []string{ + ``, ``, + ``, ``, `
              `, + ``, ``, ``, + ``, ``, + } + for _, html := range cases { + rep := Run(html, Options{}) + for _, f := range rep.Blocked { + if f.Hint == "" { + t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html) + } + } + } +} + +// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint. +func TestRun_HintsForAllWarnTags(t *testing.T) { + cases := []string{ + `x`, `
              x
              `, + `x`, `x`, + } + for _, html := range cases { + rep := Run(html, Options{}) + for _, f := range rep.Applied { + if f.Hint == "" { + t.Errorf("warn rule %s missing hint for %q", f.RuleID, html) + } + } + } +} + +// TestClassifyTag_Coverage exercises classifyTag with every category. +func TestClassifyTag_Coverage(t *testing.T) { + if k, _ := classifyTag("p"); k != "allow" { + t.Errorf("p classified as %q", k) + } + if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked { + t.Errorf("script classified as %q/%q", k, id) + } + if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan { + t.Errorf("font classified as %q/%q", k, id) + } + // Niche tag passes silently (e.g.
              ). + if k, _ := classifyTag("details"); k != "allow" { + t.Errorf("niche tag
              should pass through, got %q", k) + } + // Case-insensitive. + if k, _ := classifyTag("SCRIPT"); k != "error" { + t.Errorf("SCRIPT (uppercase) should still classify as error") + } +} + +// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only, +// no-scheme variants. +func TestClassifyURLValue_CoverageEdges(t *testing.T) { + cases := map[string]string{ + "": "ok", + " ": "ok", + "https://x": "ok", + "https://x/path?q=1": "ok", + "#fragment": "ok", + "/relative": "ok", + "javascript:alert(1)": "error", + "vbscript:msgbox 1": "error", + "data:image/png;base64,XYZ": "ok", + "data:text/html,x` + + `

              y

              ` + rep := Run(html, Options{}) + if len(rep.Blocked) < 4 { + t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked) + } +} + +// TestRun_NestedStructurePreserved verifies deep nesting passes through. +func TestRun_NestedStructurePreserved(t *testing.T) { + html := `

              deep

              ` + rep := Run(html, Options{}) + if len(rep.Blocked) != 0 { + t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked) + } + if !strings.Contains(rep.CleanedHTML, "deep") { + t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a +// blocked tag inside an allowed parent leaves the parent intact. +func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) { + html := `
              beforeafter
              ` + rep := Run(html, Options{}) + if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML) + } + if strings.Contains(rep.CleanedHTML, "
                nested +// directly without an
              • wrapper triggers LIST_DIRECT_CHILD_NON_LI and +// the inner
                  ends up wrapped in a synthetic
                • . Same for
                      . +func TestRun_ListDirectChildNonLIWrapped(t *testing.T) { + cases := []struct { + name string + html string + }{ + {"ul wraps ul", `
                        • x
                      `}, + {"ol wraps ol", `
                        1. x
                      `}, + {"ul wraps div", `
                        orphan
                      • real
                      `}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep := Run(tc.html, Options{}) + gotRule := false + for _, f := range rep.Applied { + if f.RuleID == RuleListDirectChildNonLI { + gotRule = true + break + } + } + if !gotRule { + t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied) + } + // The cleaned HTML should not have a direct ul>ul or ol>ol or + // ul>div sequence anymore. + if strings.Contains(rep.CleanedHTML, "
                        wrapper, cleaned=%q", rep.CleanedHTML) + } + }) + } +} diff --git a/shortcuts/mail/lint/rules.go b/shortcuts/mail/lint/rules.go new file mode 100644 index 000000000..746bad428 --- /dev/null +++ b/shortcuts/mail/lint/rules.go @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lint + +import "strings" + +// Rule IDs surfaced through Finding.RuleID. UPPER_SNAKE_CASE naming is the +// contract for the stdout envelope. New rules MUST keep this naming convention +// so AI / test consumers can pattern-match reliably. +const ( + // Tag-level rules. + RuleTagFontToSpan = "TAG_FONT_TO_SPAN" + RuleTagCenterToDiv = "TAG_CENTER_TO_DIV" + RuleTagMarqueeToText = "TAG_MARQUEE_TO_TEXT" + RuleTagBlinkToText = "TAG_BLINK_TO_TEXT" + RuleTagScriptBlocked = "TAG_SCRIPT_BLOCKED" + RuleTagIframeBlocked = "TAG_IFRAME_BLOCKED" + RuleTagObjectBlocked = "TAG_OBJECT_BLOCKED" + RuleTagEmbedBlocked = "TAG_EMBED_BLOCKED" + RuleTagFormBlocked = "TAG_FORM_BLOCKED" + RuleTagInputBlocked = "TAG_INPUT_BLOCKED" + RuleTagLinkBlocked = "TAG_LINK_BLOCKED" + RuleTagMetaBlocked = "TAG_META_BLOCKED" + RuleTagBaseBlocked = "TAG_BASE_BLOCKED" + RuleTagUnknownStripped = "TAG_UNKNOWN_STRIPPED" + + // Attribute-level rules. + RuleAttrEventHandlerBlocked = "ATTR_EVENT_HANDLER_BLOCKED" + RuleAttrJSURLBlocked = "ATTR_JS_URL_BLOCKED" + RuleAttrUnsafeSchemeBlocked = "ATTR_UNSAFE_SCHEME_BLOCKED" + + // Style-level rules. + RuleStylePropertyDropped = "STYLE_PROPERTY_DROPPED" + + // Feishu-native autofix rules. These autofix the inline style / + // class / nesting shape of common elements so AI-authored HTML + // matches what Feishu mail-editor itself emits, fixing the visual + // "extra blank line between blocks", "list bullets/numbers missing", + // "link color wrong" etc. classes of issues. The rewrite is purely + // additive — user-supplied inline styles take precedence; the lib + // only fills the missing properties. + RuleStyleListNative = "STYLE_LIST_NATIVE_INLINE_APPLIED" + RuleStyleListItemNative = "STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED" + RuleStyleBlockquoteNative = "STYLE_BLOCKQUOTE_NATIVE_INLINE_APPLIED" + RuleStyleLinkNative = "STYLE_LINK_NATIVE_INLINE_APPLIED" + RuleStyleParaWrapper = "STYLE_PARA_WRAPPER_REWRITTEN" + + // RuleListDirectChildNonLI fires when a
                          or
                            has a non-
                          1. + // element child (e.g. nested
                                ). HTML spec requires list children + // to be
                              • ; browsers silently hoist the nested list out and the visual + // nesting falls apart. The lib autofixes by wrapping the offending child + // in a synthetic
                              • . + RuleListDirectChildNonLI = "LIST_DIRECT_CHILD_NON_LI" +) + +// Tag classification ---------------------------------------------------------- + +// allowedTags enumerates tags that pass through verbatim (tag classification row "通过"). +// Lower-case canonical names; the parser normalises tag names so we don't need +// case-insensitive comparison at lookup time. +var allowedTags = map[string]bool{ + "p": true, + "div": true, + "span": true, + "br": true, + "hr": true, + "a": true, + "img": true, + "table": true, + "thead": true, + "tbody": true, + "tfoot": true, + "tr": true, + "td": true, + "th": true, + "ul": true, + "ol": true, + "li": true, + "blockquote": true, + "pre": true, + "code": true, + "b": true, + "i": true, + "em": true, + "strong": true, + "u": true, + "s": true, + "strike": true, + "h1": true, + "h2": true, + "h3": true, + "h4": true, + "h5": true, + "h6": true, + "sub": true, + "sup": true, + "section": true, + "article": true, + "header": true, + "footer": true, + "nav": true, + "main": true, + "figure": true, + "figcaption": true, + "caption": true, + "colgroup": true, + "col": true, + // Document structural tags (golang.org/x/net/html always wraps fragments + // in ); we treat them as transparent so the wrapper + // nodes the parser inserts don't generate spurious findings. + "html": true, + "head": true, + "body": true, +} + +// blockedTags enumerates tags whose content is removed in full and a +// SeverityError finding is emitted (tag classification row "错误(删除)"). Each entry +// maps to the rule id surfaced in Finding.RuleID. +var blockedTags = map[string]string{ + "script": RuleTagScriptBlocked, + "iframe": RuleTagIframeBlocked, + "object": RuleTagObjectBlocked, + "embed": RuleTagEmbedBlocked, + "form": RuleTagFormBlocked, + "input": RuleTagInputBlocked, + "select": RuleTagInputBlocked, + "option": RuleTagInputBlocked, + "button": RuleTagInputBlocked, + "link": RuleTagLinkBlocked, + "meta": RuleTagMetaBlocked, + "base": RuleTagBaseBlocked, +} + +// warnAutofixTags enumerates tags rewritten when AutoFix is true (tag +// classification row "警告 + 自动修复"). The replacement strategy is per-tag. +var warnAutofixTags = map[string]string{ + "font": RuleTagFontToSpan, + "center": RuleTagCenterToDiv, + "marquee": RuleTagMarqueeToText, + "blink": RuleTagBlinkToText, +} + +// classifyTag returns the rule kind for the given lower-case tag name. +// +// kind is one of "allow", "warn", "error", "unknown". For "warn" / "error", +// ruleID names the firing rule; for "unknown", the caller falls back to +// allow-list-by-default but emits a hint via RuleTagUnknownStripped only when +// the tag is structurally suspect (e.g. -like). The cli's existing +// `htmlTagRe` regex is the de-facto allow-list shipping with the codebase, so +// we don't aggressively flag anything outside `allowedTags` — drop-through +// preserves user intent for niche tags (e.g. `
                                ` / ``) that +// browsers + Feishu native renderer already handle. +func classifyTag(tag string) (kind, ruleID string) { + tag = strings.ToLower(tag) + if allowedTags[tag] { + return "allow", "" + } + if id, ok := blockedTags[tag]; ok { + return "error", id + } + if id, ok := warnAutofixTags[tag]; ok { + return "warn", id + } + // Unknown / niche tags: pass through silently. The cli's existing + // `htmlTagRe` (mail_quote.go:333) tolerates them too. Users authoring + // HTML in Feishu native classes (`adit-html-block*`, `history-quote-*`, + // `lark-mail-doc-quote`) hit this path — they MUST pass through unchanged + // so reply / forward quote markup survives lint round-trips. + return "allow", "" +} + +// Attribute / URL / style classification -------------------------------------- + +// allowedURLSchemes lists URL schemes that pass through hyperlink-bearing +// attrs (`href`, `src`, `cite`, `formaction` etc.). Allowed: http(s), mailto, +// cid, data:image/*; everything else (notably javascript: and vbscript:) is +// blocked. Empty / relative URLs (no scheme) are always +// allowed because they resolve relatively at render time and pose no +// injection vector. +var allowedURLSchemes = map[string]bool{ + "http": true, + "https": true, + "mailto": true, + "cid": true, +} + +// blockedURLSchemes is the explicit deny-list. data:image/* is special-cased +// in classifyURLValue. +var blockedURLSchemes = map[string]bool{ + "javascript": true, + "vbscript": true, + "file": true, +} + +// classifyURLValue returns ("ok", "") if the URL value is acceptable, or +// ("error", ruleID) when it must be removed (javascript:/vbscript:/file:), +// or ("warn", ruleID) when the scheme is unrecognised but not actively +// dangerous. Empty values pass through (browsers ignore them). +func classifyURLValue(raw string) (kind, ruleID string) { + value := strings.TrimSpace(raw) + if value == "" { + return "ok", "" + } + // Strip leading whitespace + control bytes that could obscure the + // scheme (e.g. "java\tscript:..."). The html-parser already strips + // stray whitespace at attribute boundaries; this is defence-in-depth + // for older clients that paste from Word with U+0009 / U+0020 inside + // the scheme prefix. + value = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7F { + return -1 + } + return r + }, value) + + // Find the colon delimiter; everything before it is the scheme. + colon := strings.IndexByte(value, ':') + if colon < 0 { + // No scheme → relative URL → allow. + return "ok", "" + } + scheme := strings.ToLower(value[:colon]) + rest := value[colon+1:] + + switch { + case allowedURLSchemes[scheme]: + return "ok", "" + case scheme == "data": + // data:image/* is whitelisted; anything else (e.g. data:text/html;...) + // is rejected. The check tolerates any subtype under image/* (png / + // jpeg / gif / svg+xml / webp) so users embedding base64 thumbnails + // don't trip the rule. + rest = strings.TrimSpace(rest) + if strings.HasPrefix(strings.ToLower(rest), "image/") { + return "ok", "" + } + return "error", RuleAttrJSURLBlocked + case blockedURLSchemes[scheme]: + return "error", RuleAttrJSURLBlocked + default: + // Unknown scheme: surface a warning so users see it but don't + // drop legitimate webcal:/tel: / similar in case downstream + // renders eventually support them. + return "warn", RuleAttrUnsafeSchemeBlocked + } +} + +// urlAttributes lists attributes whose value is a URL and must therefore +// pass classifyURLValue. Lower-case canonical names. +var urlAttributes = map[string]bool{ + "href": true, + "src": true, + "cite": true, + "formaction": true, + "action": true, + "background": true, + "poster": true, +} + +// allowedStyleProps enumerates CSS property names that pass through the +// inline `style="..."` attribute. Everything else is removed from the +// property list and surfaced via STYLE_PROPERTY_DROPPED. +// +// `border-*` / `padding-*` / `margin-*` are treated as prefix matches by +// classifyStyleProperty so the four directional variants (border-top etc.) +// are all admitted without enumerating each. +var allowedStyleProps = map[string]bool{ + "color": true, + "background-color": true, + "font-size": true, + "font-weight": true, + "font-style": true, + "text-align": true, + "text-decoration": true, + "line-height": true, + "padding": true, + "margin": true, + "border": true, + "width": true, + "height": true, + "display": true, + "text-indent": true, + // Quote-block / native Feishu styles (tag classification "通过"). + // Whitespace + word-break are part of the existing `
                                ` / quote
                                +	// wrapper styles in mail_quote.go (e.g. `bodyDivStyle`).
                                +	"white-space":         true,
                                +	"word-break":          true,
                                +	"word-wrap":           true,
                                +	"overflow":            true,
                                +	"overflow-wrap":       true,
                                +	"vertical-align":      true,
                                +	"list-style":          true,
                                +	"list-style-type":     true,
                                +	"list-style-position": true,
                                +	"transition":          true,
                                +	"font-family":         true,
                                +	"text-transform":      true,
                                +	"hyphens":             true,
                                +	"max-width":           true,
                                +	"min-width":           true,
                                +	"max-height":          true,
                                +	"min-height":          true,
                                +	"border-radius":       true,
                                +	"box-sizing":          true,
                                +	"opacity":             true,
                                +	"cursor":              true,
                                +}
                                +
                                +// stylePropAllowedPrefixes enumerates property name prefixes treated as
                                +// allowed regardless of suffix (e.g. "border-*"). A trailing "-" makes the
                                +// prefix self-documenting.
                                +var stylePropAllowedPrefixes = []string{
                                +	"border-",
                                +	"padding-",
                                +	"margin-",
                                +}
                                +
                                +// classifyStyleProperty reports whether the given lower-case property name
                                +// is in the allow-list (incl. prefix matches).
                                +func classifyStyleProperty(name string) bool {
                                +	name = strings.ToLower(strings.TrimSpace(name))
                                +	if name == "" {
                                +		return false
                                +	}
                                +	if allowedStyleProps[name] {
                                +		return true
                                +	}
                                +	for _, p := range stylePropAllowedPrefixes {
                                +		if strings.HasPrefix(name, p) {
                                +			return true
                                +		}
                                +	}
                                +	return false
                                +}
                                +
                                +// isEventHandlerAttr reports whether the attribute name is a DOM event
                                +// handler (`on*`). The lib removes every such attribute regardless of its
                                +// value (tag classification row "错误(删除)" + the well-known XSS vector).
                                +func isEventHandlerAttr(name string) bool {
                                +	name = strings.ToLower(strings.TrimSpace(name))
                                +	if !strings.HasPrefix(name, "on") {
                                +		return false
                                +	}
                                +	if len(name) <= 2 {
                                +		return false
                                +	}
                                +	// Defence-in-depth: avoid matching legitimate attrs whose name happens
                                +	// to begin with "on" (e.g. `onerror`-like attrs all start "on" + ascii
                                +	// letter). The `>= 'a'` check filters out "on-something" with hyphens.
                                +	c := name[2]
                                +	return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                +}
                                diff --git a/shortcuts/mail/lint/types.go b/shortcuts/mail/lint/types.go
                                new file mode 100644
                                index 000000000..383b1e6db
                                --- /dev/null
                                +++ b/shortcuts/mail/lint/types.go
                                @@ -0,0 +1,92 @@
                                +// Copyright (c) 2026 Lark Technologies Pte. Ltd.
                                +// SPDX-License-Identifier: MIT
                                +
                                +// Package lint implements the mail-domain HTML lint lib used by `+lint-html`
                                +// and the writing-path internals of the compose 5 shortcuts (`+send`,
                                +// `+draft-create`, `+reply`, `+reply-all`, `+forward`) and `+draft-edit` body
                                +// ops. The lib classifies HTML tags / attributes / inline styles into three
                                +// tiers (pass / warn-and-autofix / error-delete) following the three-tier tag
                                +// classification. `
                                +
                                +
                                + +
                                +

                                [调研主题] 市场调研报告

                                +
                                [YYYY-MM-DD] | 调研者:[姓名] · [团队] | [关联系统 / 版本]
                                +
                                + +
                                +

                                调研背景

                                +
                                [一段话描述:本轮调研聚焦的赛道 / 行业背景 / 触发动机]。本轮调研覆盖 [N] 类玩家([类别 1] / [类别 2] / [类别 3] / [类别 4]),重点评估 [自家产品 / 团队] 在 [赛道名] 的位置、对外摩擦点,以及结合 [关联工作 / PR / 本期目标] 的待补能力。所有结论基于 [数据来源 1:公开资料 / 厂商文档 / 行业报告] + [数据来源 2:自有实测 / 内部调研笔记] + [数据来源 3:访谈 / 体验]。
                                +
                                + +
                                +
                                +
                                [N]
                                +
                                调研对象
                                +
                                +
                                +
                                [N]
                                +
                                已就绪能力
                                +
                                +
                                +
                                [N]
                                +
                                明确缺口
                                +
                                +
                                +
                                [N]
                                +
                                高优待办
                                +
                                +
                                + +
                                +

                                1. [章节标题:例 "全球市场态势"]

                                +
                                [一句话描述本节切分维度,例 "把市场按 '为谁设计' 切四象限"]
                                +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
玩家 / 对象定位 / 类型[关键评分维度]关键观察
[玩家 1][类别][标签][一句话观察]
[玩家 2][类别][标签][一句话观察]
[玩家 3][类别][标签][一句话观察]
[玩家 4][类别][标签][一句话观察]
+ + +

+

2. [章节标题:例 "接入摩擦点"] ⚠️ 风险

+
[一句话描述:从哪里观察 / 案例 / 数据来源]
+ + + + + + + + + + + + + + + + + + + + + + + +
摩擦类型 / 维度具体表现业务影响
[摩擦 1][具体表现 / 案例][对业务 / 团队的影响]
[摩擦 2][具体表现][影响]
[摩擦 3][具体表现][影响]
+
+ +
+

3. [章节标题:例 "新势力玩家详情" / "重点对象详细比较"]

+
+
+
[玩家 / 对象 1]
+
[一句话产品定位 / 核心能力 / 差异化]
+
关键差异:[一句话提炼]
+
+
+
[玩家 / 对象 2]
+
[产品定位]
+
关键差异:[一句话]
+
+
+
[玩家 / 对象 3]
+
[产品定位]
+
关键差异:[一句话]
+
+
+
[小结一句话:玩家共性 / 自家路线对比]
+
+ +
+

4. [章节标题:例 "安全风险全景" / "潜在隐患"] ⚠️ 高危

+
[一句话描述:风险来源 / 关联前期工作]
+ + + + + + + + + + + + + + + + + + + + + + + +
威胁 / 风险案例 / 来源自家现状
[风险 1][案例 / 来源链接 / 引用前期报告][标签]
[风险 2][案例 / 来源][标签]
[风险 3](重点)[案例 / 来源][标签]
+
+ 结论:[一段话,提炼本章节最关键的判断 / 行动建议] +
+
+ +
+

5. [章节标题:例 "自家已就绪能力"] ✓ 优势

+
[一句话描述:基于哪些 PR / 已交付的工作得出]
+
  • [能力 1] — [简述 + 关联 PR / 文档链接]
  • [能力 2] — [简述]
  • [能力 3] — [简述]
  • [能力 4] — [简述]
+
+ +
+

6. [章节标题:例 "待补能力 / 机会清单"]

+
[一句话描述:清单口径 / 优先级判定依据]
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#优先级能力 / 缺口建议落地
1P0[能力 / 缺口 1][具体落地路径 / Owner / 估算]
2P0[能力 / 缺口 2][具体落地路径]
3P1[能力 / 缺口 3][具体落地路径]
4P1[能力 / 缺口 4][具体落地路径]
5P2[能力 / 缺口 5][具体落地路径]
+
+ +
+

关联工作产出佐证

+
本调研报告中部分章节的依据来自下列在执行中的工作:
+ +
+ +
+

建议与下一步

+
  1. [行动 1] — [具体路径 + 时间窗 + Owner]
  2. [行动 2] — [具体路径 + 时间窗]
  3. [行动 3] — [具体路径]
  4. [行动 4] — [具体路径]
+
+ +
+
调研者:[your@email] · [团队]|整合于 [YYYY-MM-DD]
+
关联材料:[文档 / 笔记路径 / 前期报告]
+
+ + diff --git a/skills/lark-mail/assets/templates/weekly--personal-report.html b/skills/lark-mail/assets/templates/weekly--personal-report.html new file mode 100644 index 000000000..2e1bd7567 --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--personal-report.html @@ -0,0 +1,43 @@ + +
[姓名] 个人工作周报 · [YYYY 第 NN 周]
+
[团队] · [角色]|周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]
+ +
本周工作内容
+ +
1. [项目 / 主任务名称]已完成 · 📄 文档 · PR 链接
+
  • [子项 1.1:动作描述,附数据 / 链接]
  • [子项 1.2:动作描述]
  • [子项 1.3:动作描述,含具体数字 / 占比 / 时长]
+ +
2. [项目 / 主任务名称]进行中 · 📄 文档
+
  • [子项 2.1:动作 + 当前进度 + 数据]
  • [子项 2.2:动作 + 当前进度]
+ +
3. [项目 / 主任务名称]已完成
+
  • [子项 3.1]
  • [子项 3.2]
+ +
下周工作内容
+ +
1. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
+
  • [子项 1.1:具体动作 + 推进方式,例「先 spike POC,再发 RFC 同协作方对齐方案」]
  • [子项 1.2:里程碑 / 关键产出 + 完成方式]
  • [子项 1.3:依赖 / 协作方 / 验收标准]
+ +
2. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
+
  • [子项 2.1:动作 + 推进方式]
  • [子项 2.2:里程碑 / 关键产出]
  • [子项 2.3:依赖 / 验收]
+ +
3. [项目 / 主任务名称]P1 · 预计 [YYYY-MM-DD]
+
  • [子项 3.1:动作 + 推进方式]
  • [子项 3.2:里程碑]
  • [子项 3.3:协作方]
+ +
4. [项目 / 主任务名称]P2 · 预计 [YYYY-MM-DD]
+
  • [子项 4.1:动作 + 推进方式]
  • [子项 4.2:依赖 / 关键产出]
+ +
风险与疑问
+
  • [风险 / 疑问 1] — [背景:描述风险来源 / 触发场景];[影响:会延期 / 阻塞哪些工作];[建议:希望得到的支持 / 决策方向 / 期望响应方(@姓名 / 团队)]
  • [风险 / 疑问 2] — [背景];[影响];[建议]
  • [风险 / 疑问 3] — [背景];[影响];[建议]
+
(若本周无风险 / 疑问,整段替换为:。)
+ +
— [姓名] / [团队] / [日期]|[your@email]
diff --git a/skills/lark-mail/assets/templates/weekly--team-report.html b/skills/lark-mail/assets/templates/weekly--team-report.html new file mode 100644 index 000000000..6d26a90c0 --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--team-report.html @@ -0,0 +1,9 @@ + +
本周工作
+
  1. [项目 / 事件 1 名称]@[姓名 a]@[姓名 b]
    文档:[文档名]
  2. [项目 / 事件 2 名称]@[姓名 g]
    技术方案:[文档名] · 设计稿:[设计稿名]
    • [子项 2.1:含孙子项的动作主题]
      • [孙子项 2.1.1:必要时再细分一层;不需要可整段删除]@[姓名 h]
      • [孙子项 2.1.2]
    • [子项 2.2]@[姓名 i],进行中
    • [子项 2.3]@[姓名 j],评审中
  3. [项目 / 事件 3 名称]@[姓名 k]@[姓名 l]阻塞
    阻塞分析:[文档名]
+
下周工作
+
  1. [重点 1:项目 / 事件名]@[姓名 o],预计 [YYYY-MM-DD]
  2. [重点 2:含子重点的项目]
    1. [子重点 a:动作 / 推进方式]@[姓名 p]
    2. [子重点 b:动作]@[姓名 q]
  3. [重点 3:项目 / 事件名]@[姓名 r]@[姓名 s],预计 [YYYY-MM-DD]
  4. [重点 4:项目 / 事件名]@[姓名 t],预计 [YYYY-MM-DD]
+
— [姓名] / [团队] / [日期]|[your@email]
diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index eeb016af9..36b6682dd 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -8,6 +8,8 @@ 如需修改已有草稿,不要使用此命令,请使用 `lark-cli mail +draft-edit`。 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + ## 安全约束 此命令创建草稿——**不会**发送邮件。用户可以在飞书邮件 UI 中打开草稿查看详情,确认后再进入后续操作。因此: @@ -44,7 +46,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te |------|------|------| | `--to ` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice ` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) | | `--subject ` | 是 | 草稿主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | +| `--body ` | 二选一 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 | +| `--body-file ` | 二选一 | 从文件读取邮件正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB | | `--from ` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。省略时使用邮箱主地址 | | `--mailbox ` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 | | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 366c5cf82..a4b4c5759 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -10,11 +10,13 @@ - `--set-cc` - `--set-bcc` -**正文编辑和其他高级操作必须通过 `--patch-file`**。没有 `--set-body` flag。 +**正文整体替换的快捷方式:** `--body ` / `--body-file `(二选一互斥)会自动展开为 `set_body` op。如果只想做整段正文替换且不需要保留引用区,用这两个 flag 即可,无需写 patch-file。要保留引用区或做更精细的 op 组合,仍走 `--patch-file`。两个入口与 `--patch-file` 内的 `set_body` / `set_reply_body` 互斥。 -### 正文编辑:两个 op 的选择 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** -正文编辑通过 `--patch-file` 传入,有两个 op 可选: +## 正文编辑:快捷 flag 与 typed op 的选择 + +整段替换正文且不需要保留引用区时,可直接使用 `--body` / `--body-file`。需要保留引用区、修改引用区或组合高级正文编辑时,通过 `--patch-file` 传入 typed body op,有两个 op 可选: | 情况 | op | 行为 | |------|-----|------| @@ -49,7 +51,10 @@ # 编辑草稿元数据(主题、收件人) lark-cli mail +draft-edit --draft-id --set-subject '更新后的主题' --set-to alice@example.com,bob@example.com -# 编辑草稿正文(必须通过 patch-file) +# 快速完整替换正文 +lark-cli mail +draft-edit --draft-id --body '

更新后的正文

' + +# 高级正文编辑(如保留回复/转发引用区) lark-cli mail +draft-edit --draft-id --patch-file ./patch.json # 查看草稿(只读)— 返回包含 has_quoted_content、attachments_summary 和 inline_summary 的投影 @@ -72,13 +77,15 @@ lark-cli mail +draft-edit --draft-id --set-subject '测试' --dry-run | `--set-to ` | 否 | 用此处提供的地址替换整个 To 收件人列表 | | `--set-cc ` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 | | `--set-bcc ` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 | +| `--body ` | 否 | 整段替换正文(自动展开为 `set_body` op)。与 `--body-file` 互斥;与 `--patch-file` 内的 `set_body` / `set_reply_body` op 互斥 | +| `--body-file ` | 否 | 从文件读取正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB | | `--set-priority ` | 否 | 设置邮件优先级:`high`、`normal`、`low`。设为 `normal` 会清除已有优先级 | | `--set-event-summary ` | 否 | 设置日程标题。需同时设置 `--set-event-start` 和 `--set-event-end` | | `--set-event-start