mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Compare commits
8 Commits
feat/slide
...
fix/skills
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4472a1d6b | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -277,7 +283,28 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -292,15 +319,22 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -310,16 +344,24 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,33 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
- minute wait (#1768)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||
|
||||
### Documentation
|
||||
|
||||
- tighten doc creation validation workflow (#1759)
|
||||
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **envvars**: consolidate agent env value access (#1757)
|
||||
|
||||
### Misc
|
||||
|
||||
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
@@ -1371,6 +1398,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||
// It uses the same source as schema execution so completion candidates match
|
||||
// what `schema` can resolve.
|
||||
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) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// runSchema resolves the path through the schema catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
catalog := registry.SchemaCatalog()
|
||||
if len(catalog.Services()) == 0 {
|
||||
// No embedded metadata and the runtime fallback is empty too: offline
|
||||
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||
}
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
|
||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
pm := "npm"
|
||||
install := updater.RunNpmInstall
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
pm = "pnpm"
|
||||
install = updater.RunPnpmInstall
|
||||
}
|
||||
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
npmResult := install(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
"hint": permissionHint(combined, pm),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
if hint := permissionHint(combined, pm); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
hint := verificationFailureHint(updater, latest, pm)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if skillsResult != nil {
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
skillsPM := "npx"
|
||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||
skillsPM = "pnpm dlx"
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
if pm == "pnpm" {
|
||||
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||
}
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||
// and fails the test if the npm install path is invoked.
|
||||
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.PnpmInstallOverride = pnpmFn
|
||||
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||
t.Errorf("npm install must not be called for a pnpm install")
|
||||
return &selfupdate.NpmResult{}
|
||||
}
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "via pnpm") {
|
||||
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||
)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error exit")
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||
t.Errorf("expected failure envelope, got: %s", out)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||
}
|
||||
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
hint = permissionHint("EACCES: permission denied", "npm")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
if got := permissionHint("some other error", "npm"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
||||
// space→dot fallback covers domains where the two already coincide.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
|
||||
@@ -6,12 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -40,8 +38,6 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
||||
return SourceValue + "/" + build.Version
|
||||
}
|
||||
|
||||
// AgentTraceValue returns a header-safe value from the
|
||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
||||
// surrounding whitespace, rejects values containing any Unicode
|
||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
||||
// for any invalid or empty value. Callers can use the result
|
||||
// directly in HTTP headers without further sanitisation.
|
||||
func AgentTraceValue() string {
|
||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
||||
if v == "" || len(v) > agentTraceMaxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BaseSecurityHeaders returns headers that every request must carry.
|
||||
func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTraceValue(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " ")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(envvars.CliAgentTrace, val)
|
||||
if got := AgentTraceValue(); got != val {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// Content safety scanning mode
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
|
||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
agentNameMaxLen = 128
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
func AgentName() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||
}
|
||||
|
||||
func AgentTrace() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||
}
|
||||
|
||||
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" || len(v) > maxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\x01injected")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||
t.Setenv(CliAgentName, longVal)
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTrace(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTrace(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " ")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(CliAgentTrace, longVal)
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(CliAgentTrace, val)
|
||||
if got := AgentTrace(); got != val {
|
||||
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -6,8 +6,7 @@ package registry
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
||||
func RuntimeCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||
}
|
||||
|
||||
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||
// nothing to resolve; the merged view is the only data such binaries have.
|
||||
func SchemaCatalog() apicatalog.Catalog {
|
||||
if len(EmbeddedServicesTyped()) > 0 {
|
||||
return EmbeddedCatalog()
|
||||
}
|
||||
return RuntimeCatalog()
|
||||
}
|
||||
|
||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||
// restores them (with a full state reset) on cleanup.
|
||||
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
resetInit()
|
||||
orig := embeddedMetaJSON
|
||||
embeddedMetaJSON = data
|
||||
t.Cleanup(func() {
|
||||
waitBackgroundRefresh()
|
||||
embeddedMetaJSON = orig
|
||||
resetInit()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||
}
|
||||
if _, ok := c.Service("embedded_svc"); !ok {
|
||||
t.Fatal("expected embedded_svc from embedded metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||
// Init seeds via sync fetch.
|
||||
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write(testEnvelopeJSON("remote_svc"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
testMetaURL = ts.URL
|
||||
|
||||
c := SchemaCatalog()
|
||||
|
||||
if c.Source() != apicatalog.SourceRuntime {
|
||||
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||
}
|
||||
if _, ok := c.Service("remote_svc"); !ok {
|
||||
t.Fatal("expected remote_svc from runtime fallback")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
// After a CLI upgrade the embedded data can be fresher than an old
|
||||
// cache; an equal/older cache must not shadow it.
|
||||
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
}
|
||||
|
||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||
// marker, tagged with the given top-level data version and brand.
|
||||
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||
t.Helper()
|
||||
cDir := filepath.Join(dir, "cache")
|
||||
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := MergedRegistry{
|
||||
Version: version,
|
||||
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||
}
|
||||
data, _ := json.Marshal(reg)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||
mData, _ := json.Marshal(cm)
|
||||
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||
t.Helper()
|
||||
embedded, _ := json.Marshal(MergedRegistry{
|
||||
Version: embeddedVer,
|
||||
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||
})
|
||||
swapEmbeddedMeta(t, embedded)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||
InitWithBrand(core.BrandFeishu)
|
||||
}
|
||||
|
||||
func titleOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
svc, ok := ServiceTyped(name)
|
||||
if !ok {
|
||||
t.Fatalf("service %q not loaded", name)
|
||||
}
|
||||
return svc.Title
|
||||
}
|
||||
|
||||
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "2.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "2.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||
initWithCache(t, "1.0.0", "not-a-semver")
|
||||
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||
// plugin builds without compiled meta_data.json still get remote data.
|
||||
initWithCache(t, "0.0.0", "1.0.0")
|
||||
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||
// so cache overlay passes the version gate in InitWithBrand.
|
||||
func testRegistry(name string) MergedRegistry {
|
||||
return MergedRegistry{
|
||||
Version: "test-1.0",
|
||||
Version: "1.0.0",
|
||||
Services: []meta.Service{
|
||||
{
|
||||
Name: name,
|
||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
resetInit()
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||
resetInit()
|
||||
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
if reg.Version != "test-1.0" {
|
||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
||||
if reg.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -53,22 +54,32 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
switch d.Method {
|
||||
case InstallNpm:
|
||||
return d.NpmAvailable
|
||||
case InstallPnpm:
|
||||
return d.PnpmAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
switch {
|
||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||
return "installed via pnpm, but pnpm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm"
|
||||
return "not installed via npm or pnpm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
@@ -101,17 +113,38 @@ type Updater struct {
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
|
||||
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||
// binary was installed cannot change during a single process, so caching is
|
||||
// the correct semantics — and it is required for correctness: the update
|
||||
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||
// skills, so a re-detection at skills time could resolve a now-stale
|
||||
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||
// sequential.
|
||||
detectCache *DetectResult
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
if u.detectCache != nil {
|
||||
return *u.detectCache
|
||||
}
|
||||
result := u.detectInstallMethod()
|
||||
u.detectCache = &result
|
||||
return result
|
||||
}
|
||||
|
||||
// detectInstallMethod performs the real (uncached) detection.
|
||||
func (u *Updater) detectInstallMethod() DetectResult {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
_, npmErr := exec.LookPath("npm")
|
||||
_, pnpmErr := exec.LookPath("pnpm")
|
||||
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||
}
|
||||
|
||||
// detectFromResolved classifies the resolved binary path into an install
|
||||
// method and records package-manager availability. Split out from
|
||||
// DetectInstallMethod so the classification is unit-testable without touching
|
||||
// the filesystem or PATH.
|
||||
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||
switch method {
|
||||
case InstallNpm:
|
||||
d.NpmAvailable = npmOnPath
|
||||
case InstallPnpm:
|
||||
d.PnpmAvailable = pnpmOnPath
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||
// whose resolved path runs through pnpm's home directory (e.g.
|
||||
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||
// followed by "store". Matching only these two shapes (rather than any bare
|
||||
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||
// classification is OS-independent and unit-testable anywhere.
|
||||
func containsPnpmMarker(p string) bool {
|
||||
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == ".pnpm" {
|
||||
return true
|
||||
}
|
||||
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||
if u.PnpmInstallOverride != nil {
|
||||
return u.PnpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
pnpmPath, err := exec.LookPath("pnpm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
||||
return u.runSkillsCommand(args...)
|
||||
}
|
||||
|
||||
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||
// launcher selection is unit-testable on any platform.
|
||||
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||
if method == InstallPnpm && pnpmAvailable {
|
||||
r := args
|
||||
if len(r) > 0 && r[0] == "-y" {
|
||||
r = r[1:]
|
||||
}
|
||||
return "pnpm", append([]string{"dlx"}, r...)
|
||||
}
|
||||
return "npx", args
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
det := u.DetectInstallMethod()
|
||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||
binPath, err := exec.LookPath(launcher)
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPnpmMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Classic virtual-store layout (.pnpm segment).
|
||||
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/usr/local/bin/lark-cli", false},
|
||||
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||
// "pnpm" immediately followed by "store".
|
||||
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsPnpmMarker(c.path); got != c.want {
|
||||
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||
u := &Updater{DetectOverride: nil}
|
||||
u.DetectOverride = func() DetectResult {
|
||||
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||
}
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm {
|
||||
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||
}
|
||||
if !got.PnpmAvailable {
|
||||
t.Errorf("PnpmAvailable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||
}
|
||||
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||
t.Error("pnpm available should CanAutoUpdate")
|
||||
}
|
||||
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualReason_Pnpm(t *testing.T) {
|
||||
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||
t.Errorf("pnpm reason = %q", got)
|
||||
}
|
||||
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||
t.Errorf("manual reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||
return r
|
||||
}}
|
||||
got := u.RunPnpmInstall("2.0.0")
|
||||
if got.Err != nil {
|
||||
t.Fatalf("unexpected err: %v", got.Err)
|
||||
}
|
||||
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsInvocation(t *testing.T) {
|
||||
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
method InstallMethod
|
||||
pnpmAvailable bool
|
||||
args []string
|
||||
wantLauncher string
|
||||
wantRest []string
|
||||
}{
|
||||
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||
if gotLauncher != c.wantLauncher {
|
||||
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||
}
|
||||
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||
// stays consistent with what was detected — and reported — before the update.
|
||||
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
u := New()
|
||||
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||
u.detectCache = &cached
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.65",
|
||||
"version": "1.0.66",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||
|
||||
function normalizeRepoPath(input) {
|
||||
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||
}
|
||||
|
||||
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
function findPathMapping(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||
}
|
||||
|
||||
function labelDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||
}
|
||||
|
||||
function e2eDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||
}
|
||||
|
||||
function matchesFullFallback(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippablePath(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||
|| (domainMap.skipFilenames || []).includes(basename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
domainMap,
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
labelDomainsForPath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
};
|
||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"pathMappings": [
|
||||
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||
|
||||
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||
],
|
||||
"fullFallbackPrefixes": [
|
||||
"shortcuts/common/",
|
||||
"cmd/",
|
||||
"internal/",
|
||||
"pkg/",
|
||||
"extension/",
|
||||
"registry/",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"Makefile",
|
||||
".github/workflows/",
|
||||
"scripts/"
|
||||
],
|
||||
"skipPrefixes": [
|
||||
"docs/",
|
||||
".changeset/"
|
||||
],
|
||||
"skipSuffixes": [
|
||||
".md",
|
||||
".mdx",
|
||||
".txt",
|
||||
".rst"
|
||||
],
|
||||
"skipFilenames": [
|
||||
"readme.md",
|
||||
"readme.zh.md",
|
||||
"changelog.md",
|
||||
"license",
|
||||
"cla.md"
|
||||
]
|
||||
}
|
||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
} = require("./domain-map");
|
||||
|
||||
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||
process.chdir(ROOT);
|
||||
|
||||
function execLines(command, args) {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function modulePath() {
|
||||
return execLines("go", ["list", "-m"])[0];
|
||||
}
|
||||
|
||||
function rootPackage(moduleName) {
|
||||
return `${moduleName}/tests/cli_e2e`;
|
||||
}
|
||||
|
||||
function allLivePackages(moduleName) {
|
||||
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||
}
|
||||
|
||||
function allDryPackages(moduleName) {
|
||||
return allLivePackages(moduleName);
|
||||
}
|
||||
|
||||
const domainExistsCache = new Map();
|
||||
|
||||
function domainExists(domain) {
|
||||
if (domainExistsCache.has(domain)) {
|
||||
return domainExistsCache.get(domain);
|
||||
}
|
||||
let exists = false;
|
||||
try {
|
||||
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
domainExistsCache.set(domain, exists);
|
||||
return exists;
|
||||
}
|
||||
|
||||
function readChangedFiles() {
|
||||
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||
if (changedFilesPath) {
|
||||
return fs.readFileSync(changedFilesPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeRepoPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addDomain(domains, domain) {
|
||||
if (domain && domainExists(domain)) {
|
||||
domains.add(domain);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyPath(filePath, domains) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||
if (e2eMatch) {
|
||||
const domain = e2eMatch[1];
|
||||
if (domain === "demo") return { matched: false };
|
||||
if (domainExists(domain)) {
|
||||
addDomain(domains, domain);
|
||||
return { matched: true };
|
||||
}
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||
}
|
||||
|
||||
if (matchesFullFallback(normalized)) {
|
||||
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
const mappedDomains = e2eDomainsForPath(normalized);
|
||||
if (mappedDomains.length > 0) {
|
||||
const missingDomains = [];
|
||||
for (const domain of mappedDomains) {
|
||||
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||
}
|
||||
if (missingDomains.length > 0) {
|
||||
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||
}
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
if (findPathMapping(normalized)) {
|
||||
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
|
||||
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
function resolveDomains(changedFiles) {
|
||||
const moduleName = modulePath();
|
||||
const rootDryPackage = rootPackage(moduleName);
|
||||
if (changedFiles === null) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: "non-pull_request run or unavailable diff",
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
const domains = new Set();
|
||||
let matchedRelevant = false;
|
||||
let fullReason = "";
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const result = classifyPath(file, domains);
|
||||
if (result.matched) matchedRelevant = true;
|
||||
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||
}
|
||||
|
||||
if (fullReason) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: fullReason,
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRelevant && domains.size > 0) {
|
||||
const sortedDomains = [...domains].sort();
|
||||
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||
return {
|
||||
mode: "subset",
|
||||
reason: "business domain changes",
|
||||
domains: sortedDomains,
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: packages,
|
||||
livePackages: packages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "skip",
|
||||
reason: "docs-only or no live CLI E2E impact",
|
||||
domains: [],
|
||||
dryRootPackage: "",
|
||||
dryPackages: [],
|
||||
livePackages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(resolved) {
|
||||
const values = {
|
||||
mode: resolved.mode,
|
||||
reason: resolved.reason,
|
||||
domains: resolved.domains.join(","),
|
||||
dry_root_package: resolved.dryRootPackage,
|
||||
dry_packages: resolved.dryPackages.join(" "),
|
||||
live_packages: resolved.livePackages.join(" "),
|
||||
};
|
||||
|
||||
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
emit(resolveDomains(readChangedFiles()));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPath,
|
||||
readChangedFiles,
|
||||
resolveDomains,
|
||||
};
|
||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||
|
||||
function parseOutput(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.trim().split(/\r?\n/)) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runDomains(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||
const file = path.join(dir, "changed.txt");
|
||||
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||
try {
|
||||
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("maps shortcut changes to one business domain package", () => {
|
||||
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "im");
|
||||
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("maps doc shortcuts to docs package", () => {
|
||||
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "docs");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||
});
|
||||
|
||||
test("maps direct e2e domain package changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "drive");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("falls back to full for shared e2e harness changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||
});
|
||||
|
||||
test("falls back to full for runtime changes", () => {
|
||||
const output = runDomains(["cmd/root.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared\/runtime path changed/);
|
||||
});
|
||||
|
||||
test("skips docs-only changes", () => {
|
||||
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||
assert.equal(output.mode, "skip");
|
||||
assert.equal(output.domains, "");
|
||||
assert.equal(output.dry_root_package, "");
|
||||
assert.equal(output.live_packages, "");
|
||||
});
|
||||
|
||||
test("uses shared map for skill domain changes", () => {
|
||||
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "sheets");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||
});
|
||||
|
||||
test("falls back to full when a mapped path has no e2e package", () => {
|
||||
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cleanup_tmp() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$tmp" && return 0
|
||||
sleep 1
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -306,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
}
|
||||
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||
resultData["recurrence"] = recurrence
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
var rows []map[string]interface{}
|
||||
|
||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||
type calendarEventTime struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventVChat mirrors the vchat block in the API response.
|
||||
type calendarEventVChat struct {
|
||||
VCType string `json:"vc_type,omitempty"`
|
||||
IconType string `json:"icon_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MeetingURL string `json:"meeting_url,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventLocation mirrors the location block in the API response.
|
||||
type calendarEventLocation struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventReminder mirrors a reminder entry.
|
||||
type calendarEventReminder struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
|
||||
// calendarEventOrganizer mirrors event_organizer.
|
||||
type calendarEventOrganizer struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventAttachment mirrors a single attachment entry.
|
||||
type calendarEventAttachment struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
FileSize string `json:"file_size,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||
type calendarEventCheckInTime struct {
|
||||
TimeType string `json:"time_type,omitempty"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// calendarEventCheckIn mirrors event_check_in.
|
||||
type calendarEventCheckIn struct {
|
||||
EnableCheckIn bool `json:"enable_check_in"`
|
||||
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||
}
|
||||
|
||||
// calendarEvent mirrors the event object inside the API response.
|
||||
type calendarEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||
Location *calendarEventLocation `json:"location,omitempty"`
|
||||
Color int `json:"color,omitempty"`
|
||||
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IsException bool `json:"is_exception,omitempty"`
|
||||
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||
CreateTime string `json:"create_time,omitempty"`
|
||||
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||
}
|
||||
|
||||
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||
rawEvent, ok := data["event"]
|
||||
if !ok || rawEvent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||
}
|
||||
raw, err := json.Marshal(rawEvent)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var event calendarEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// buildCalendarEventOutput converts the typed event into the output map and
|
||||
// applies the four transformation rules:
|
||||
// 1. create_time -> RFC3339
|
||||
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||
// 3. flatten event into the top-level result
|
||||
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(startMap, "timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||
delete(endMap, "timestamp")
|
||||
}
|
||||
}
|
||||
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CalendarGet gets a single calendar event detail.
|
||||
var CalendarGet = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+get",
|
||||
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "event-id", Desc: "event ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
d := common.NewDryRunAPI()
|
||||
switch calendarId {
|
||||
case "":
|
||||
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||
calendarId = "<primary>"
|
||||
case "primary":
|
||||
calendarId = "<primary>"
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
return d.
|
||||
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||
Set("calendar_id", calendarId).
|
||||
Set("event_id", eventId)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarId == "" {
|
||||
calendarId = PrimaryCalendarIDStr
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event, err := parseCalendarEvent(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := buildCalendarEventOutput(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
summary, _ := out["summary"].(string)
|
||||
if summary == "" {
|
||||
summary = "(untitled)"
|
||||
}
|
||||
startMap, _ := out["start_time"].(map[string]interface{})
|
||||
endMap, _ := out["end_time"].(map[string]interface{})
|
||||
startStr, _ := startMap["datetime"].(string)
|
||||
if startStr == "" {
|
||||
startStr, _ = startMap["date"].(string)
|
||||
}
|
||||
endStr, _ := endMap["datetime"].(string)
|
||||
if endStr == "" {
|
||||
endStr, _ = endMap["date"].(string)
|
||||
}
|
||||
eventIdOut, _ := out["event_id"].(string)
|
||||
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||
row := map[string]interface{}{
|
||||
"event_id": eventIdOut,
|
||||
"summary": summary,
|
||||
"start": startStr,
|
||||
"end": endStr,
|
||||
"free_busy_status": freeBusyStatus,
|
||||
"self_rsvp_status": selfRsvpStatus,
|
||||
}
|
||||
output.PrintTable(w, []map[string]interface{}{row})
|
||||
fmt.Fprintln(w)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -2304,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
func TestShortcuts_Returns10(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 10 {
|
||||
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
@@ -3178,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarGet tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Daily Sync",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_001",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||
if strings.Contains(out, "\"event\": {") {
|
||||
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||
t.Errorf("expected event_id in output, got: %s", out)
|
||||
}
|
||||
// status=confirmed should be dropped
|
||||
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||
}
|
||||
// timestamp must be replaced with datetime
|
||||
if strings.Contains(out, "\"timestamp\":") {
|
||||
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"datetime\":") {
|
||||
t.Errorf("expected datetime in output, got: %s", out)
|
||||
}
|
||||
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_002",
|
||||
"summary": "Cancelled Meeting",
|
||||
"create_time": "1602504000",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
"status": "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_002",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_003",
|
||||
"summary": "All-day",
|
||||
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||
"status": "confirmed",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_003",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--event-id", " ",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error for empty event-id")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "--event-id" {
|
||||
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarGet, []string{
|
||||
"+get",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--event-id", "evt_404",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error when event field is missing")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
CalendarGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
aborted := false
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
"aborted": aborted,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -35,6 +35,7 @@ type drivePushItem struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
Hint string
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
aborted := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
if uploadFailed && aborted {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
if drivePushIsAlreadyDeleted(err) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
||||
continue
|
||||
}
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
"aborted": aborted,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Hint: decision.Hint,
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Code == 1061044:
|
||||
decision.Class = "parent_node_missing"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
func drivePushIsAlreadyDeleted(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
return ok && problem.Code == 1061007
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
|
||||
@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061007,
|
||||
"msg": "file has been delete.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// --- Phase 2: Execute sync operations ---
|
||||
var pulled, pushed, skipped, failed int
|
||||
aborted := false
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
if aborted {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
"aborted": aborted,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
|
||||
@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
case 1061101:
|
||||
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
|
||||
case 233523001:
|
||||
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
|
||||
case 99991400:
|
||||
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateMarkdownSpec(runtime, spec, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
return normalizeMarkdownCreateTargetSpec(spec)
|
||||
}
|
||||
|
||||
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||
if spec.FolderToken != "" {
|
||||
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.FolderToken = token
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.WikiToken = token
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||
}
|
||||
if ref.Type != "folder" {
|
||||
return "", markdownValidationParamError("--folder-token",
|
||||
"--folder-token must identify a Drive folder; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch markdownKnownResourceTokenKind(token) {
|
||||
case "wiki":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||
WithHint("Pass it with --wiki-token instead.")
|
||||
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token",
|
||||
"--wiki-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||
if strings.ContainsAny(token, "/?#") {
|
||||
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||
if err := validate.ResourceName(token, flagName); err != nil {
|
||||
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownKnownResourceTokenKind(token string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(token))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "wik"):
|
||||
return "wiki"
|
||||
case strings.HasPrefix(lower, "docx"):
|
||||
return "docx"
|
||||
case strings.HasPrefix(lower, "doc"):
|
||||
return "doc"
|
||||
case strings.HasPrefix(lower, "sht"):
|
||||
return "sheet"
|
||||
case strings.HasPrefix(lower, "bas"):
|
||||
return "bitable"
|
||||
case strings.HasPrefix(lower, "mn"):
|
||||
return "mindnote"
|
||||
case strings.HasPrefix(lower, "sld"):
|
||||
return "slides"
|
||||
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||
return "file"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected folder-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() (string, error)
|
||||
wantMsg string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "wiki token passed as folder token",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||
wantMsg: "--folder-token looks like a wiki node token",
|
||||
wantHint: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "folder token path fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||
wantMsg: "--folder-token must be a raw token",
|
||||
wantHint: "full Lark URL",
|
||||
},
|
||||
{
|
||||
name: "doc token passed as wiki token",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||
wantMsg: "--wiki-token must be a wiki node token",
|
||||
wantHint: "",
|
||||
},
|
||||
{
|
||||
name: "wiki token query fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||
wantMsg: "--wiki-token must be a raw token",
|
||||
wantHint: "path/query/fragment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||
}
|
||||
if folderToken != "folder_token_raw" {
|
||||
t.Fatalf("folder token = %q", folderToken)
|
||||
}
|
||||
|
||||
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||
}
|
||||
if wikiToken != "wik_placeholder_raw" {
|
||||
t.Fatalf("wiki token = %q", wikiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||
t.Fatalf("quota hint = %q", p.Hint)
|
||||
}
|
||||
|
||||
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||
p, ok = errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||
}
|
||||
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ import (
|
||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||
|
||||
// Error codes from the minutes API.
|
||||
const minutesDetailNoReadPermissionCode = 2091005
|
||||
const (
|
||||
minutesDetailProcessingCode = 2091003
|
||||
minutesDetailNoReadPermissionCode = 2091005
|
||||
minutesDetailWaitTimeoutDefault = 300
|
||||
minutesDetailWaitIntervalDefault = 15
|
||||
)
|
||||
|
||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
||||
// minuteDetailItem represents a single minute detail result.
|
||||
type minuteDetailItem struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Title string `json:"title"`
|
||||
NoteID string `json:"note_id"`
|
||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||
waitReady := runtime.Bool("wait-ready")
|
||||
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||
|
||||
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
needKeyword := runtime.Bool("keyword")
|
||||
|
||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||
}
|
||||
} else {
|
||||
artifacts := make(map[string]any)
|
||||
if needSummary {
|
||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
return result
|
||||
}
|
||||
|
||||
func isMinutesDetailProcessingError(err error) bool {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||
}
|
||||
if intervalSeconds <= 0 {
|
||||
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||
}
|
||||
return timeoutSeconds, intervalSeconds
|
||||
}
|
||||
|
||||
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
data, err := call()
|
||||
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||
return data, err
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || interval > remaining {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||
var flags []string
|
||||
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||
if runtime.Bool(flag) {
|
||||
flags = append(flags, "--"+flag)
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||
result.Status = "processing"
|
||||
result.Retryable = true
|
||||
result.Error = reason
|
||||
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||
}
|
||||
|
||||
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||
parts = append(parts, artifactFlags...)
|
||||
parts = append(parts, "--wait-ready")
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
if r.Status == "processing" {
|
||||
row["status"] = "PROCESSING"
|
||||
} else {
|
||||
row["status"] = "FAIL"
|
||||
}
|
||||
row["error"] = r.Error
|
||||
if r.NextCommand != "" {
|
||||
row["next_command"] = r.NextCommand
|
||||
}
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
row["title"] = r.Title
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func detailProcessingStub(path string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: path,
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091003,
|
||||
"msg": "minute is processing",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesDetail.Mount(parent, f)
|
||||
parent.SetOut(stdout)
|
||||
parent.SetArgs([]string{"+detail", "--help"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("help failed: %v", err)
|
||||
}
|
||||
help := stdout.String()
|
||||
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["retryable"] != true {
|
||||
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" {
|
||||
t.Fatalf("status = %v, want processing", m["status"])
|
||||
}
|
||||
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||
t.Fatalf("next_command = %v", m["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["title"] != "Ready Metadata" {
|
||||
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||
}
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
arts, _ := m["artifacts"].(map[string]any)
|
||||
if arts == nil {
|
||||
t.Fatal("expected artifacts")
|
||||
}
|
||||
if arts["summary"] != "Test summary content" {
|
||||
t.Fatalf("summary = %v", arts["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
m := firstDetailMinute(t, stdout.Bytes())
|
||||
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
var callCount int
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||
Reusable: true,
|
||||
OnMatch: func(req *http.Request) { callCount++ },
|
||||
})
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||
}
|
||||
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||
if timeout != 9 || interval != 3 {
|
||||
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||
}
|
||||
}
|
||||
|
||||
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||
func chdirForDetailTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,6 +5,8 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||
outData["minute_token"] = minuteToken
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func extractUploadedMinuteToken(minuteURL string) string {
|
||||
u, err := url.Parse(minuteURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "minutes" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||
{name: "invalid", url: "://bad", want: ""},
|
||||
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
maxSlidesPerScreenshot = 10
|
||||
)
|
||||
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
|
||||
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
@@ -35,7 +32,7 @@ var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
var SlidesScreenshot = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
@@ -45,8 +42,8 @@ var SlidesScreenshot = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
{Name: "output-name", Desc: "file name stem for --content render output"},
|
||||
@@ -73,17 +70,12 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
if !hasSlideScreenshotSelector(runtime) {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
|
||||
return err
|
||||
@@ -106,9 +98,6 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
@@ -156,9 +145,6 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
@@ -281,11 +267,8 @@ func normalizeSlideNumbers(values []int) ([]int, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateSlidesScreenshotSelectorLimit(count int) error {
|
||||
if count > maxSlidesPerScreenshot {
|
||||
return slidesScreenshotFlagErrorf("too many slide selectors: got %d, maximum is %d; request at most 10 pages at a time", count, maxSlidesPerScreenshot)
|
||||
}
|
||||
return nil
|
||||
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
|
||||
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
|
||||
}
|
||||
|
||||
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
|
||||
|
||||
@@ -271,33 +271,6 @@ func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRejectsMoreThanTenSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "1",
|
||||
"--slide-number", "2",
|
||||
"--slide-number", "3",
|
||||
"--slide-number", "4",
|
||||
"--slide-number", "5",
|
||||
"--slide-number", "6",
|
||||
"--slide-number", "7",
|
||||
"--slide-number", "8",
|
||||
"--slide-number", "9",
|
||||
"--slide-number", "10",
|
||||
"--slide-number", "11",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request at most 10 pages at a time") {
|
||||
t.Fatalf("error = %v, want max 10 pages guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
@@ -6,6 +6,7 @@ package wiki
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -26,3 +27,17 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
||||
}
|
||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||
}
|
||||
|
||||
func appendWikiProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -130,6 +132,147 @@ func TestWikiNodeListRequiresSpaceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsNonNumericSpaceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "wikcnABC", "--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected numeric space_id validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--space-id" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--space-id", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "--space-id must be a numeric wiki space_id") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("expected numeric space_id validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsDocumentURLAsParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list",
|
||||
"--space-id", "7211568716812369922",
|
||||
"--parent-node-token", "https://feishu.cn/docx/docxABC",
|
||||
"--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--parent-node-token" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--parent-node-token", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("https://feishu.cn/wiki/wikcnPARENT?from=copy")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != "wikcnPARENT" {
|
||||
t.Fatalf("token = %q, want wikcnPARENT", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateWikiNodeListSpaceID("https://example.invalid/wiki/space"); err == nil {
|
||||
t.Fatalf("expected URL space-id validation error")
|
||||
} else {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "not a URL or path") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("problem = %#v, want URL/path message and +space-list hint", p)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "partial wiki path",
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := normalizeWikiNodeListParentToken(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent token validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsEmptyParentToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken(empty) error = %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListProblemAddsActionableHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidParameters, "param err: invalid page_token").WithCode(131002)
|
||||
got := wikiNodeListProblem(err, nil)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "page token is invalid or stale") {
|
||||
t.Fatalf("hint = %q, want invalid page token guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -137,14 +280,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_node_1",
|
||||
"obj_token": "docx_1",
|
||||
"obj_type": "docx",
|
||||
@@ -154,7 +297,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
"has_child": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_node_2",
|
||||
"obj_token": "docx_2",
|
||||
"obj_type": "docx",
|
||||
@@ -170,7 +313,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -211,14 +354,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
@@ -235,7 +378,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -286,7 +429,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "space_personal_42",
|
||||
"space_id": "7211568716812369923",
|
||||
"name": "My Library",
|
||||
"space_type": "my_library",
|
||||
},
|
||||
@@ -296,14 +439,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
// Step 2: list nodes in the resolved space.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_personal_42",
|
||||
"space_id": "7211568716812369923",
|
||||
"node_token": "wik_personal_1",
|
||||
"title": "Personal Note",
|
||||
},
|
||||
@@ -334,8 +477,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
|
||||
if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,21 +901,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
|
||||
// test pins down the "default = single page" contract.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "tok_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
|
||||
map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -802,14 +945,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"space_id": "7211568716812369922",
|
||||
"node_token": "wik_1",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docx_1",
|
||||
@@ -822,7 +965,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
|
||||
@@ -48,27 +48,19 @@ var WikiNodeList = common.Shortcut{
|
||||
"--space-id my_library is a per-user alias and is only valid with --as user.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
|
||||
if _, err := readWikiNodeListSpec(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
||||
params["parent_node_token"] = pt
|
||||
if spec.ParentNodeToken != "" {
|
||||
params["parent_node_token"] = spec.ParentNodeToken
|
||||
}
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
@@ -80,7 +72,7 @@ var WikiNodeList = common.Shortcut{
|
||||
// When the caller passes my_library, +node-list must first resolve it
|
||||
// to the real per-user space_id before listing nodes, mirroring the
|
||||
// two-step orchestration used by +node-create.
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
if spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
return d.
|
||||
Desc("2-step orchestration: resolve my_library -> list nodes").
|
||||
GET("/open-apis/wiki/v2/spaces/my_library").
|
||||
@@ -91,13 +83,17 @@ var WikiNodeList = common.Shortcut{
|
||||
Set("space_id", "<resolved_space_id>")
|
||||
}
|
||||
return d.
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
|
||||
Params(params).
|
||||
Set("space_id", spaceID)
|
||||
Set("space_id", spec.SpaceID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaceID := spec.SpaceID
|
||||
|
||||
// Resolve the my_library alias to the per-user real space_id before
|
||||
// listing, so the subsequent request hits a concrete space endpoint.
|
||||
@@ -110,7 +106,7 @@ var WikiNodeList = common.Shortcut{
|
||||
spaceID = resolved
|
||||
}
|
||||
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,10 +123,104 @@ var WikiNodeList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
|
||||
type wikiNodeListSpec struct {
|
||||
SpaceID string
|
||||
ParentNodeToken string
|
||||
}
|
||||
|
||||
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
|
||||
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
|
||||
if err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
|
||||
}
|
||||
|
||||
func validateWikiNodeListSpaceID(spaceID string) error {
|
||||
if spaceID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
}
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id, not a URL or path",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
|
||||
}
|
||||
if !isDecimalWikiSpaceID(spaceID) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDecimalWikiSpaceID(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
if parentNodeToken == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(parentNodeToken, "://") {
|
||||
ref, ok := common.ParseResourceURL(parentNodeToken)
|
||||
if !ok {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token URL is unsupported",
|
||||
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
|
||||
}
|
||||
parentNodeToken = ref.Token
|
||||
}
|
||||
if strings.ContainsAny(parentNodeToken, "/?#") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parentNodeToken, nil
|
||||
}
|
||||
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
|
||||
pageSize := runtime.Int("page-size")
|
||||
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
|
||||
auto := wikiListShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
|
||||
@@ -153,7 +243,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
return nil, false, "", wikiNodeListProblem(err, runtime)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -177,6 +267,36 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
||||
return nodes, lastHasMore, lastPageToken, nil
|
||||
}
|
||||
|
||||
func wikiNodeListProblem(err error, runtime *common.RuntimeContext) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
switch p.Code {
|
||||
case 131002:
|
||||
msg := strings.ToLower(p.Message)
|
||||
switch {
|
||||
case strings.Contains(msg, "page_token"):
|
||||
appendWikiProblemHint(err, "The page token is invalid or stale. Use only the `page_token` returned by the immediately preceding `wiki +node-list` response, or omit --page-token and start over.")
|
||||
case strings.Contains(msg, "space_id"):
|
||||
appendWikiProblemHint(err, "The --space-id value must be the numeric wiki space_id from `wiki +space-list`; do not pass a wiki URL, node token, document token, or title.")
|
||||
default:
|
||||
appendWikiProblemHint(err, "Check the wiki +node-list flags. Fix the parameter before retrying; this is not a transient error.")
|
||||
}
|
||||
case 131005:
|
||||
appendWikiProblemHint(err, "The target wiki space or parent node was not found. Re-discover the space with `wiki +space-list` and the parent with `wiki +node-list`/`wiki +node-get`; do not retry the same stale token.")
|
||||
case 131006:
|
||||
if runtime != nil && runtime.As().IsBot() {
|
||||
appendWikiProblemHint(err, "The bot/app identity cannot read this wiki space or node. Grant the app the required wiki scope and ensure the app or bot has access to the target knowledge space.")
|
||||
} else {
|
||||
appendWikiProblemHint(err, "The current user cannot read this wiki space or node. Switch to a user with access or ask the space owner to grant read permission.")
|
||||
}
|
||||
case 99991400:
|
||||
appendWikiProblemHint(err, "Rate limited by the wiki API. Stop immediate retries and retry later with exponential backoff or a smaller --page-limit.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(m, "space_id"),
|
||||
|
||||
@@ -76,4 +76,4 @@ CLI 提供三种互斥的 scope 表达方式:
|
||||
## 不在本 skill 范围
|
||||
|
||||
- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。
|
||||
- 身份选择、权限不足处理(`permission_violations`→`console_url`)、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
|
||||
- 身份选择、权限不足处理(`missing_scopes`→`console_url`)、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
|
||||
|
||||
@@ -85,7 +85,7 @@ metadata:
|
||||
## 身份与权限降级
|
||||
|
||||
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。
|
||||
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
|
||||
- user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
|
||||
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次;bot 仍失败就停止重试并按权限错误处理。
|
||||
- `91403` 或明确不可访问错误不要循环换身份重试。
|
||||
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。
|
||||
|
||||
@@ -98,16 +98,35 @@ lark-cli base +dashboard-block-get \
|
||||
|
||||
## 返回结构总览
|
||||
|
||||
服务端响应外层仍然是标准 OpenAPI 包装:
|
||||
CLI 输出标准成功信封(判断成功用 `ok == true` 或退出码 0,不要找 `code == 0`):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"dimensions": [...],
|
||||
"measures": [...],
|
||||
"main_data": [...]
|
||||
"dimensions": [
|
||||
{
|
||||
"field_name": "地区",
|
||||
"alias": "dim_region"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
{
|
||||
"field_name": "销售额",
|
||||
"alias": "me_sales"
|
||||
}
|
||||
],
|
||||
"main_data": [
|
||||
{
|
||||
"dim_region": {
|
||||
"value": "华东"
|
||||
},
|
||||
"me_sales": {
|
||||
"value": 12345
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -347,28 +347,51 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
|------|------|------|------|
|
||||
| `format` | string | 是 | 固定为 `"flat"`,表示返回扁平化的对象数组 |
|
||||
|
||||
## API 出参详情
|
||||
## CLI 出参详情
|
||||
|
||||
**成功时:**
|
||||
**成功时**(stdout,判断成功用 `ok == true` 或退出码 0):
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"main_data": [{"dim_city": {"value": "北京"}, "total_amount": {"value": 12345.00}}, ...]}, "msg": ""}
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"main_data": [
|
||||
{
|
||||
"dim_city": {
|
||||
"value": "北京"
|
||||
},
|
||||
"total_amount": {
|
||||
"value": 12345.00
|
||||
}
|
||||
},
|
||||
{
|
||||
"dim_city": {
|
||||
"value": "上海"
|
||||
},
|
||||
"total_amount": {
|
||||
"value": 6789.00
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败时:**
|
||||
**失败时**(stderr 类型化错误信封,非零退出码;`error.code` 是上游 API 错误码):
|
||||
|
||||
```json
|
||||
{"code": 800004006, "data": {"error": {"code": 800004006, ...}}, "msg": "DSL validation failed"}
|
||||
{"ok": false, "identity": "user", "error": {"type": "api", "subtype": "...", "code": 800004006, "message": "DSL validation failed", "hint": "..."}}
|
||||
```
|
||||
|
||||
**Response 字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | int | 状态码,0 为成功 |
|
||||
| `msg` | string | 错误信息 |
|
||||
| `ok` | bool | 是否成功 |
|
||||
| `data.main_data` | []object | 查询结果数组,每个元素为一行数据 |
|
||||
| `data.error` | object | 失败时的错误详情 |
|
||||
| `error.code` | int | 失败时的上游 API 错误码 |
|
||||
| `error.message` / `error.hint` | string | 失败原因与建议的恢复动作 |
|
||||
|
||||
每行数据的字段值封装在 CellValue 中:
|
||||
|
||||
@@ -387,7 +410,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
|
||||
## 返回值
|
||||
|
||||
命令成功后输出 `data` 字段的内容:
|
||||
命令成功后,成功信封的 `data` 字段即查询结果:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||
|
||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
**CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -30,26 +30,80 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
||||
| `+agenda` | 查看日程安排(默认今天) |
|
||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
| `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
|
||||
### `+get` — 单日程详情
|
||||
|
||||
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
|
||||
|
||||
```bash
|
||||
# calendar_id不传,默认primary
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||
|
||||
```bash
|
||||
# query 按关键词 可选
|
||||
# start/end 按时间范围(ISO 8601 或 YYYY-MM-DD)可选
|
||||
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
|
||||
# page-token 分页游标,用于继续翻页 可选
|
||||
# page-size 每页数量,默认 30 可选
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
|
||||
```
|
||||
|
||||
### `+agenda` — 查看近期日程安排
|
||||
|
||||
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# calendar-id 日历 ID(默认primary)可选
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
|
||||
```
|
||||
|
||||
注意:
|
||||
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
|
||||
- 时间范围超过 40 天会自动拆分查询并合并结果。
|
||||
|
||||
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
|
||||
|
||||
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
|
||||
|
||||
```bash
|
||||
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||
# user-id 目标用户 open_id(ou_ 前缀)可选;默认当前登录用户,bot 身份必须显式指定
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
```
|
||||
|
||||
用法提示:
|
||||
- **仅判断是否有空** → `+freebusy`;**需要日程详情** → `+agenda`。
|
||||
- 检查多人可用性:分别调用并对比,找共同空闲。
|
||||
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
|
||||
|
||||
## 前置条件路由
|
||||
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id` |
|
||||
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
|
||||
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 写操作反馈
|
||||
|
||||
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||
@@ -72,7 +126,8 @@ lark-cli calendar +agenda --as user
|
||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
|
||||
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||
|
||||
## 任务类型分流
|
||||
@@ -90,7 +145,7 @@ lark-cli calendar +agenda --as user
|
||||
|
||||
## 会议室规则
|
||||
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)。
|
||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||
@@ -98,42 +153,45 @@ lark-cli calendar +agenda --as user
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
# 通用调用格式
|
||||
lark-cli calendar <resource> <method> [flags]
|
||||
|
||||
# 查询用户主日历
|
||||
lark-cli calendar calendars primary
|
||||
|
||||
# 获取日程分享链接
|
||||
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
|
||||
|
||||
# 删除日程
|
||||
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
### calendars
|
||||
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
|
||||
|
||||
- `create` — 创建共享日历
|
||||
- `delete` — 删除共享日历
|
||||
- `get` — 查询日历信息
|
||||
- `list` — 查询日历列表
|
||||
- `patch` — 更新日历信息
|
||||
- `primary` — 查询用户主日历
|
||||
- `search` — 搜索日历
|
||||
### 查询资源的方法列表以及方法的使用方式
|
||||
|
||||
### event.attendees
|
||||
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
|
||||
- 查看方法的cli flag:`lark-cli calendar <resource> <method> -h`
|
||||
- 查看方法API参数:`lark-cli schema calendar.<resource>.<method>`
|
||||
|
||||
- `batch_delete` — 删除日程参与人
|
||||
- `create` — 添加日程参与人
|
||||
- `list` — 获取日程参与人列表
|
||||
`<resource>` 为 `calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`。
|
||||
|
||||
### events
|
||||
## 常用其他域命令
|
||||
|
||||
- `create` — 创建日程
|
||||
- `delete` — 删除日程
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `share_info` — 获取日程分享链接
|
||||
```bash
|
||||
# 搜索用户,更多参数详见 lark-contact
|
||||
lark-cli contact +search-user --query <query> --as user
|
||||
|
||||
### freebusys
|
||||
|
||||
- `list` — 查询主日历日程忙闲信息
|
||||
# 搜索群聊,更多参数详见 lark-im
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
|
||||
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
# calendar +agenda
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查看近期日程安排。只读操作,不修改任何日程。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看今天日程(默认)
|
||||
lark-cli calendar +agenda
|
||||
|
||||
# 自定义时间范围(ISO 8601)
|
||||
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +agenda --format pretty
|
||||
|
||||
# 指定日历
|
||||
lark-cli calendar +agenda --calendar-id cal_xxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-10` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
**将结果整理为易读的日程表:**
|
||||
|
||||
```
|
||||
## 2026-03-10 周一
|
||||
|
||||
09:00 - 09:30 站会
|
||||
10:00 - 11:00 产品评审
|
||||
14:00 - 15:00 与 Alice 1:1
|
||||
|
||||
## 2026-03-11 周二
|
||||
|
||||
(无日程)
|
||||
```
|
||||
|
||||
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
|
||||
|
||||
## 提示
|
||||
|
||||
- 已取消的日程会自动过滤,无需额外处理。
|
||||
- 如无日程,告知用户"日程清空"。
|
||||
- 大于 40 天的时间范围会自动拆分查询并合并结果。
|
||||
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,12 +1,9 @@
|
||||
|
||||
# calendar +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建日程并按需邀请参会人。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -38,10 +35,10 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
@@ -56,44 +53,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
||||
|
||||
```bash
|
||||
# 第一步:创建日程(含高级参数)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.create
|
||||
## 创建日程
|
||||
lark-cli calendar events create \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
||||
--data '{
|
||||
"summary": "技术分享:CLI 架构设计",
|
||||
"start_time": { "timestamp": "1741586400" },
|
||||
"end_time": { "timestamp": "1741593600" }
|
||||
}'
|
||||
|
||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.event.attendees.create
|
||||
## 添加参会人
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||
|
||||
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||
|
||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.delete
|
||||
## 删除空日程
|
||||
lark-cli calendar events delete \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
|
||||
|
||||
```
|
||||
|
||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
|
||||
完整 API 命令的关键差异:
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||
|
||||
## 参会人类型
|
||||
|
||||
@@ -109,6 +78,5 @@ lark-cli calendar events delete \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
# calendar +freebusy
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
查询用户主日历的忙闲信息,返回指定时间范围内的忙碌时段列表和rsvp的状态。
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询当前用户今天的忙闲(默认)
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 自定义时间范围(仅日期)
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
|
||||
|
||||
# 自定义时间范围(完整 ISO 8601)
|
||||
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
|
||||
|
||||
# 查询指定用户的忙闲信息
|
||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||
|
||||
# 人类可读格式输出
|
||||
lark-cli calendar +freebusy --format pretty
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--start <time>` | 否 | 查询开始时间(ISO 8601 或仅日期,默认当天) |
|
||||
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--user-id <open_id>` | 否 | 目标查询用户 ID(`ou_` 前缀)。省略时默认查询当前登录用户,bot 身份调用时必须明确指定 |
|
||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 时间格式
|
||||
|
||||
`--start` 和 `--end` 支持以下格式:
|
||||
|
||||
| 格式 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
|
||||
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
|
||||
| 仅日期 | `2026-03-11` | start 取 00:00:00,end 取 23:59:59 |
|
||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
||||
|
||||
## 输出示例
|
||||
|
||||
### 表格格式
|
||||
|
||||
```
|
||||
start end rsvp_status
|
||||
---------------- ---------------- -----------
|
||||
2026-03-11 10:00 2026-03-11 10:30 接受
|
||||
2026-03-11 14:00 2026-03-11 15:00 待定
|
||||
|
||||
共 2 个忙碌时段
|
||||
```
|
||||
|
||||
### JSON 格式
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"start_time": "2026-03-11T10:00:00+08:00",
|
||||
"end_time": "2026-03-11T10:30:00+08:00",
|
||||
"rsvp_status": "accept"
|
||||
},
|
||||
{
|
||||
"start_time": "2026-03-11T14:00:00+08:00",
|
||||
"end_time": "2026-03-11T15:00:00+08:00",
|
||||
"rsvp_status": "tentative"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 典型场景
|
||||
|
||||
### 1. 查找日程会议空闲时段
|
||||
|
||||
```bash
|
||||
# 查询今天的忙碌时段
|
||||
lark-cli calendar +freebusy
|
||||
|
||||
# 查询工作时间段
|
||||
lark-cli calendar +freebusy \
|
||||
--start "2026-03-11T08:00:00+08:00" \
|
||||
--end "2026-03-11T18:00:00+08:00"
|
||||
```
|
||||
|
||||
### 2. 检查团队成员可用性
|
||||
|
||||
```bash
|
||||
# 查询多个成员,对比找出共同空闲时间
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
|
||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
|
||||
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
|
||||
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
|
||||
|
||||
## 与其他命令对比
|
||||
|
||||
| 命令 | 用途 | 输出内容 |
|
||||
|------|------|----------|
|
||||
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
|
||||
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
|
||||
|
||||
**选择建议**:
|
||||
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
|
||||
- **需要查看日程详情** → 使用 `+agenda`
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
@@ -1,11 +1,8 @@
|
||||
# calendar +room-find
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 已知一个或多个待选时间块,需要查找可用会议室
|
||||
@@ -50,7 +47,7 @@ lark-cli calendar +room-find \
|
||||
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
||||
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
||||
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 |
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
@@ -67,7 +64,7 @@ lark-cli calendar +room-find \
|
||||
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
||||
- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
||||
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
||||
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
||||
@@ -90,9 +87,8 @@ lark-cli calendar +room-find \
|
||||
```
|
||||
|
||||
> **AI 行为指导:**
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。
|
||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向。
|
||||
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
|
||||
> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。
|
||||
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
||||
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
||||
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
||||
@@ -102,7 +98,7 @@ lark-cli calendar +room-find \
|
||||
| 字段名 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
||||
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
|
||||
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
|
||||
| `capacity` | 会议室最大容纳人数。 |
|
||||
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
||||
|
||||
@@ -110,4 +106,4 @@ lark-cli calendar +room-find \
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md)
|
||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# calendar +rsvp
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:reply"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -38,5 +35,4 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 明确时间分支:room-find + freebusy + 冲突处理
|
||||
|
||||
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**明确**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 查询会议室(如需)
|
||||
|
||||
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
```
|
||||
|
||||
时间块确定规则:
|
||||
- **编辑流且不改时间,只新增会议室**:`--slot` 必须来自已定位日程的当前 `start/end`
|
||||
- **编辑流且既改时间又加会议室**:`--slot` 必须来自候选新时间,而不是旧时间
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 2. 查询忙闲
|
||||
|
||||
```bash
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
### 3. 冲突处理
|
||||
|
||||
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
|
||||
- **有冲突**:必须先说明冲突情况,询问用户:
|
||||
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
|
||||
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -0,0 +1,88 @@
|
||||
# 模糊时间 / 无时间信息分支:suggestion + 批量查询
|
||||
|
||||
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||
- 任务类型判定(新建 / 编辑)
|
||||
- 编辑流:目标 event_id 已定位
|
||||
- 新建流:默认值已补全
|
||||
- 时间已判定为**模糊**或**无时间信息**
|
||||
|
||||
## 流程
|
||||
|
||||
### 1. 调用 suggestion
|
||||
|
||||
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
|
||||
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
|
||||
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
|
||||
|
||||
### 2. 分支处理
|
||||
|
||||
#### 不需要会议室
|
||||
|
||||
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
|
||||
|
||||
#### 需要会议室
|
||||
|
||||
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
|
||||
|
||||
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
|
||||
|
||||
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||
|
||||
### 3. 用户确认后
|
||||
|
||||
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
|
||||
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
|
||||
|
||||
## 模糊语义消解与长期记忆
|
||||
|
||||
针对存在歧义的时间场景,严禁主观臆断。典型例子:
|
||||
- "上班后" / "下班前"
|
||||
- 未明确上下午的 12 小时制时间
|
||||
|
||||
处理规则:
|
||||
- 主动澄清真实意图,不自行猜测
|
||||
- 用户澄清后,将个性化定义沉淀为长期偏好
|
||||
|
||||
## 用户展示格式
|
||||
|
||||
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
|
||||
## 落地
|
||||
|
||||
根据任务类型:
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||
@@ -1,206 +1,95 @@
|
||||
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
||||
|
||||
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
|
||||
## 执行摘要
|
||||
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
|
||||
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
|
||||
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
|
||||
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
|
||||
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。
|
||||
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
|
||||
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
|
||||
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
|
||||
|
||||
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
|
||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
|
||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
|
||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。
|
||||
- **新建流先补默认值,编辑流先继承已定位日程信息。**
|
||||
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
|
||||
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程。
|
||||
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤。
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
|
||||
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
|
||||
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。
|
||||
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。
|
||||
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
|
||||
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
|
||||
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
|
||||
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
|
||||
- **严禁在未读取对应子命令文档前直接调用命令。**
|
||||
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
|
||||
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
|
||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`。
|
||||
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
|
||||
|
||||
## 适用场景
|
||||
|
||||
- “帮我约个会”
|
||||
- “下周找时间和 XX 开会”
|
||||
- “帮我订个会议室”
|
||||
- “帮我找/搜索一个可用的会议室”
|
||||
- “帮我推荐一个我以前常用的会议室”
|
||||
- “查询明天下午可用的会议室”
|
||||
- “明天下午3点约个日程/日历”
|
||||
- “把明天上午的日程‘产品发布会’加上 小明
|
||||
- “给下周一的周会换个会议室”
|
||||
- “把这个日程改到明天下午,并加上学清 F201”
|
||||
- "帮我约个会" / "下周找时间和 XX 开会"
|
||||
- "帮我订/找/搜索一个可用会议室"
|
||||
- "明天下午3点约个日程"
|
||||
- "把明天上午的日程加上 小明"
|
||||
- "给下周一的周会换个会议室"
|
||||
- "把这个日程改到明天下午,并加上学清 F201"
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。
|
||||
|
||||
## CRITICAL 约束
|
||||
|
||||
- **在调用任何具体的 CLI 子命令(如 `+room-find`、`+suggestion`、`+freebusy`、`+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
|
||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
|
||||
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
|
||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||
- **预定或查找会议室,均需先确定时间块。**
|
||||
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||
|
||||
## 任务类型判定
|
||||
|
||||
| 类型 | 典型语言信号 | 第一动作 |
|
||||
|------|--------------|----------|
|
||||
| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 |
|
||||
| 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
|
||||
| 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `event_id` |
|
||||
|
||||
进一步规则:
|
||||
规则:
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`)和**修改动词**(添加、移除、改到、换),默认判定为编辑。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`。
|
||||
|
||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。
|
||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 编辑已有日程:先定位目标日程
|
||||
|
||||
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
|
||||
## 编辑流:先定位目标日程
|
||||
|
||||
定位规则:
|
||||
- 优先利用用户给出的标题、日期、时间范围等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围
|
||||
- 命中多个候选日程时,必须向用户展示候选项并要求确认
|
||||
- 重复性日程必须继续定位到该次实例的 `event_id`
|
||||
|
||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
|
||||
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
|
||||
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
|
||||
编辑流分支路由:
|
||||
|
||||
编辑流分支规则:
|
||||
| 编辑子场景 | 下一步 |
|
||||
|-----------|--------|
|
||||
| 仅增删普通参会人/群组,不改时间,不涉及会议室 | 直接 `+update`(详见 [lark-calendar-update.md](./lark-calendar-update.md)) |
|
||||
| 新增会议室,不改时间 | 基于已定位日程 start/end → [明确时间分支](./lark-calendar-schedule-clear-time.md) |
|
||||
| 只改时间,不涉及会议室 | 判断时间明确性 → 对应分支 |
|
||||
| 既改时间,又新增/更换会议室 | 先确定最终时间 → 再查会议室 → 落地 |
|
||||
|
||||
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`。
|
||||
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
|
||||
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
|
||||
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
|
||||
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`。
|
||||
## 新建日程:智能推断默认值
|
||||
|
||||
### 2. 新建日程:智能推断默认值
|
||||
以下信息智能推断,减少频繁询问用户:
|
||||
- **标题**:根据上下文自动生成;如无法推断,默认"会议"
|
||||
- **参会人**:如未指定,默认仅用户自己
|
||||
- **时长**:基于上下文推断;默认 30 分钟
|
||||
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
|
||||
|
||||
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
|
||||
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
|
||||
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
|
||||
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
|
||||
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
|
||||
|
||||
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
|
||||
|
||||
### 3. 判断时间是否明确
|
||||
|
||||
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
|
||||
## 判断时间是否明确
|
||||
|
||||
时间基准规则:
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
|
||||
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
|
||||
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
|
||||
|
||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
|
||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
|
||||
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。
|
||||
## 分支路由
|
||||
|
||||
分两类处理:
|
||||
| 判定结果 | 下一步读取 |
|
||||
|----------|-----------|
|
||||
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
|
||||
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
|
||||
|
||||
- **明确时间**:如“明天下午3点”
|
||||
- **模糊时间**:如“明天下午”“下周找个时间”
|
||||
|
||||
### 4. 明确时间
|
||||
|
||||
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
|
||||
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
|
||||
|
||||
```bash
|
||||
# 1. 如果需要会议室,提前查询会议室
|
||||
lark-cli calendar +room-find \
|
||||
--slot "<start>~<end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--city "<city>" \
|
||||
--building "<building>" \
|
||||
--floor "<F2>" \
|
||||
--room-name "<room_name>"
|
||||
|
||||
# 2. 查询当前用户及其他参会人忙闲
|
||||
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>")
|
||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **参会人过多或包含群组时的处理**:
|
||||
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
|
||||
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
|
||||
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。
|
||||
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
|
||||
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
|
||||
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
|
||||
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
||||
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。
|
||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。
|
||||
- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。
|
||||
|
||||
### 5. 模糊时间或无时间信息
|
||||
|
||||
先调用:
|
||||
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
|
||||
|
||||
```bash
|
||||
lark-cli calendar +suggestion \
|
||||
--start "<range_start>" \
|
||||
--end "<range_end>" \
|
||||
--attendee-ids "<ids>" \
|
||||
--duration-minutes <n> \
|
||||
--event-rrule "<rrule>"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
|
||||
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
|
||||
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
|
||||
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
|
||||
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
|
||||
|
||||
### 6. 模糊语义消解与长期记忆构建
|
||||
|
||||
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
|
||||
|
||||
- “上班后”
|
||||
- “下班前”
|
||||
- 未明确上下午的 12 小时制时间表达
|
||||
|
||||
处理规则:
|
||||
|
||||
- 应主动澄清真实意图,而不是自行猜测
|
||||
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
|
||||
|
||||
### 7. 重复性日程
|
||||
|
||||
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。
|
||||
|
||||
必须检查返回中的:
|
||||
|
||||
- `reserve_until_time`
|
||||
|
||||
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
|
||||
|
||||
- 向用户明确说明该会议室最长可约至何时。
|
||||
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
|
||||
|
||||
### 8. 落地日程变更
|
||||
## 落地日程变更
|
||||
|
||||
用户确认后调用:
|
||||
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
|
||||
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
|
||||
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||
|
||||
```bash
|
||||
lark-cli calendar +create \
|
||||
@@ -214,52 +103,20 @@ lark-cli calendar +update \
|
||||
--start "<start>" \
|
||||
--end "<end>" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
|
||||
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
|
||||
lark-cli calendar +update \
|
||||
--event-id "<event_id>" \
|
||||
--remove-attendee-ids "omm_old_room" \
|
||||
--add-attendee-ids "omm_new_room"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 新建日程时,可使用 `+create`
|
||||
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
|
||||
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。
|
||||
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
|
||||
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
|
||||
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
|
||||
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
|
||||
|
||||
## 用户展示建议
|
||||
|
||||
当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。
|
||||
|
||||
**推荐展示格式参考:**
|
||||
|
||||
```text
|
||||
## 2026-03-27 周五
|
||||
|
||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||
|
||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||
可用会议室:
|
||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||
|
||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||
```
|
||||
落地规则:
|
||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
|
||||
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
|
||||
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids` 新
|
||||
- 需要会议室时,将选中的 `room_id` 写入参与人列表
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-schedule-clear-time.md](./lark-calendar-schedule-clear-time.md)
|
||||
- [lark-calendar-schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md)
|
||||
- [lark-calendar-room-find.md](./lark-calendar-room-find.md)
|
||||
- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md)
|
||||
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
|
||||
- [lark-calendar-create.md](./lark-calendar-create.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
- [lark-calendar](../SKILL.md)
|
||||
- [lark-calendar-update.md](./lark-calendar-update.md)
|
||||
- [SKILL.md](../SKILL.md)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
# calendar +search-event
|
||||
|
||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词
|
||||
lark-cli calendar +search-event --query "周会"
|
||||
|
||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
||||
|
||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
||||
|
||||
# 组合
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -1,6 +1,5 @@
|
||||
# calendar +suggestion
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
|
||||
|
||||
@@ -8,8 +7,6 @@
|
||||
- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
|
||||
- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
|
||||
|
||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -121,5 +118,4 @@ lark-cli calendar +suggestion \
|
||||
## 参考
|
||||
|
||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
|
||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
||||
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# calendar +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新既有日程字段,或独立增量添加/移除参会人和会议室。
|
||||
|
||||
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
|
||||
|
||||
需要的 scopes: ["calendar:calendar.event:update"]
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
@@ -66,8 +63,8 @@ lark-cli calendar +update \
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -98,8 +95,6 @@ lark-cli calendar +update \
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
|
||||
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
|
||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲
|
||||
|
||||
@@ -54,7 +54,7 @@ lark-cli drive +member-add \
|
||||
}
|
||||
```
|
||||
|
||||
批量部分失败时,`partial` 为 `true`,CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
|
||||
批量部分失败时,`partial` 为 `true`,同一份结果以 `ok:false` 部分失败信封写到 **stdout**(stderr 不再输出单独的错误信封),CLI 以非零退出码结束。检查 `data` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
|
||||
|
||||
## 行为说明
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) |
|
||||
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`,`error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`)退出:同一份 `summary + items` 会以 `ok:false` 部分失败信封写到 **stdout**(字段在 `data.summary` / `data.items`,另附 `data.note` 说明失败情况),stderr 不再输出单独的错误信封;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation`、`error.subtype=failed_precondition`,`error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
@@ -80,7 +80,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
|
||||
- `--delete-local --yes`,**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `ok:false` 部分失败结果非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-local` → `summary.deleted_local` 永远是 0;命令对本地"多余"文件视而不见。
|
||||
|
||||
|
||||
@@ -15,15 +15,16 @@
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
| `summary.aborted` | 命中终止性错误并停止后续批处理时为 `true` |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` / `hint` / `phase` / `error_class` / `code` / `subtype` / `retryable`) |
|
||||
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `already_deleted` / `failed` / `delete_failed`。
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation`、`error.subtype=failed_precondition`,`error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
@@ -95,6 +96,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 删除阶段如果服务端返回 `1061007 file has been delete`,说明目标远端文件在本次 DELETE 前已经不存在;这已经满足 `--delete-remote` 的目标状态,输出会记为 `action: "already_deleted"`,不计入 `summary.failed`,也不计入 `summary.deleted_remote`。
|
||||
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
@@ -110,22 +112,46 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_remote": 0
|
||||
"deleted_remote": 0,
|
||||
"aborted": false
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "...", "hint": "...", "phase": "upload", "error_class": "...", "code": 0, "subtype": "...", "retryable": false},
|
||||
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
|
||||
{"rel_path": "...", "file_token": "...", "action": "already_deleted"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "...", "hint": "...", "phase": "delete", "error_class": "...", "code": 0, "subtype": "...", "retryable": false}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
|
||||
|
||||
## 失败处理与 agent 行为
|
||||
|
||||
`+push` 的失败项带结构化字段,agent 必须优先读 `items[].error_class` / `phase` / `code`,不要只看自然语言 `error` 文本。`summary.aborted=true` 表示命令已经遇到终止性错误并停止后续批处理;这时**不要原样重试**,先修复根因。
|
||||
|
||||
常见终止性错误:
|
||||
|
||||
| `error_class` | 常见 `code` | 含义 | Agent 应对 |
|
||||
|---|---:|---|---|
|
||||
| `app_scope_missing` | `99991672` | 应用身份缺少 Drive / 文件夹相关 scope | 停止重试,引导开通错误里列出的应用身份权限,例如 `space:folder:create` 或 `drive:drive` |
|
||||
| `user_scope_missing` | `99991679` | 用户身份缺少授权 | 停止重试,走 `lark-cli auth login --scope ...` 补错误里列出的 scope |
|
||||
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试,检查目标文件夹权限、身份类型(user / bot)和资源可见性 |
|
||||
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
|
||||
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
|
||||
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
|
||||
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
|
||||
|
||||
非终止但需要解释的状态:
|
||||
|
||||
- `file_size_limit` / `1061043`:文件超过 Drive 上传限制。不要继续尝试同一文件;改拆分或换存储方式。
|
||||
- `upload_size_mismatch` / `1062009`:本地文件在上传过程中发生变化,或声明大小与实际读取大小不一致。重新扫描本地文件后再 push。
|
||||
- `remote_not_found` / `1061007`:一般表示远端文件已不存在。删除阶段的 `1061007` 会被视为 `already_deleted` 成功项;其他阶段需重新列表确认远端状态。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token`、`type`、名称、大小和时间字段;其中 `created_time`、`modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,在 stderr 返回类型化错误信封(`error.type=validation`、`error.subtype=failed_precondition`);`error.params[]` 每条的 `name` 是冲突的 `rel_path`,`reason` 枚举该路径下所有碰撞条目(`type` + `file_token`)。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略(`error.hint` 也给出了同样的恢复选项)。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -76,20 +76,18 @@ lark-cli drive +status \
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"identity": "user",
|
||||
"error": {
|
||||
"type": "duplicate_remote_path",
|
||||
"message": "multiple Drive entries map to the same rel_path",
|
||||
"detail": {
|
||||
"duplicates_remote": [
|
||||
{
|
||||
"rel_path": "dup.txt",
|
||||
"entries": [
|
||||
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
|
||||
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"type": "validation",
|
||||
"subtype": "failed_precondition",
|
||||
"message": "1 rel_path(s) map to multiple Drive entries",
|
||||
"hint": "resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help",
|
||||
"params": [
|
||||
{
|
||||
"name": "dup.txt",
|
||||
"reason": "2 Drive entries collide here: file <full_file_token>, folder <folder_token>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.2.1
|
||||
version: 1.2.2
|
||||
description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -25,7 +25,8 @@ metadata:
|
||||
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`quota_exceeded`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate_limit`、`server_error` 或临时网络错误才做有限退避重试。
|
||||
- `markdown +create` 的目标参数不要猜:Drive 文件夹用 `--folder-token`,Wiki 节点用 `--wiki-token`。如果用户给的是 URL,可以直接传完整 URL;CLI 会归一成 token。不要把 doc/sheet/wiki URL 放进 `--folder-token` 试错。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
@@ -32,11 +32,21 @@ lark-cli markdown +create \
|
||||
--folder-token fldcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定文件夹(可直接传 Drive folder URL)
|
||||
lark-cli markdown +create \
|
||||
--folder-token "https://feishu.cn/drive/folder/fldcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点
|
||||
lark-cli markdown +create \
|
||||
--wiki-token wikcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点(可直接传 wiki URL)
|
||||
lark-cli markdown +create \
|
||||
--wiki-token "https://feishu.cn/wiki/wikcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
@@ -48,8 +58,8 @@ lark-cli markdown +create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token 或 Drive folder URL;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token 或 wiki URL;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
|
||||
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
@@ -58,6 +68,8 @@ lark-cli markdown +create \
|
||||
|
||||
- `--content` 与 `--file` 必须二选一
|
||||
- `--folder-token` 与 `--wiki-token` 互斥
|
||||
- `--folder-token` 只能是 Drive 文件夹;不要传 wiki/doc/sheet/base/file token 或 URL
|
||||
- `--wiki-token` 只能是 Wiki 节点;如果只有 docx/sheet/base 等文档 URL,先用 `lark-cli wiki +node-get --node-token <url>` 解析出 `node_token`
|
||||
- `--name` 必须带 `.md` 后缀
|
||||
- `--file` 指向的本地文件名也必须带 `.md` 后缀
|
||||
- 传 `--wiki-token` 时,返回值中不会附带 `/file/<token>` URL,因为 wiki 承载文件没有稳定的独立 file URL
|
||||
@@ -88,6 +100,14 @@ lark-cli markdown +create \
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `not_found` / `1061044`:父目录或 wiki 节点不存在,或 token 类型放错参数。修正 `--folder-token` / `--wiki-token` 后再试,不要重复提交同一参数。
|
||||
- `quota_exceeded` / `1061101`:目标存储空间配额已满。释放空间、换父目录/节点或请管理员扩容后再试。
|
||||
- `permission_denied` / `missing_scope`:区分身份处理。`--as user` 看用户授权和目标 ACL;`--as bot` 看应用 scope 与目标目录/节点 ACL。
|
||||
- `rate_limit`:停止立即重试,使用退避。
|
||||
- `server_error` / `233523001`:可以稍后有限重试;若重复出现,保留 `log_id` / request id 给服务端排查。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
|
||||
@@ -120,9 +120,9 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
|
||||
|
||||
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
|
||||
|
||||
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。
|
||||
**无编辑权限**:若 CLI 返回稳定字段 `error.subtype=permission_denied`,且 `error.code` 为 `40005`(`+todo` / `+word-replace` 等编辑接口)或 `2091005`(如标题更新),表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。`error.message` 只作人读说明,不要用它做分支判断。
|
||||
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回稳定字段 `error.code=40001` 且 `error.subtype=not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。`error.message` 只作人读说明,不要用它做分支判断。
|
||||
|
||||
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
# minutes +download
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下载妙记的音视频媒体文件到本地,或获取有效期 1 天的下载链接。只读操作。
|
||||
|
||||
@@ -134,4 +133,3 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +search
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id,也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。
|
||||
|
||||
@@ -199,6 +198,5 @@ lark-cli minutes +detail --minute-tokens <minute_token> --summary
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +speaker-replace
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要把外部/非飞书说话人改绑到正确飞书用户的场景。
|
||||
|
||||
@@ -106,4 +105,3 @@ Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`-
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +summary
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记的 AI 总结内容。写操作,会覆盖当前总结。
|
||||
|
||||
@@ -119,4 +118,3 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
|
||||
- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
> **路由**:本命令操作**妙记内的 AI 待办**,不是飞书任务(Task)。用户说「在妙记里新建待办」时**必须**用本命令,**禁止**走 `lark-cli task` / `tasklists list` / `task +create`。详见 [lark-minutes/SKILL.md](../SKILL.md) 第 6 节。
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
对妙记中的待办做新增 / 更新 / 删除(单条或批量)。写操作。
|
||||
|
||||
@@ -127,12 +126,11 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` |
|
||||
| `--todos` 与单条 flags 冲突 | 二选一 |
|
||||
| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 |
|
||||
| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
|
||||
| 缺少 OAuth scope(`permission_violations` 含 `minutes:minutes:update`) | `lark-cli auth login --scope "minutes:minutes:update"` |
|
||||
| `error.code=40005` 且 `error.subtype=permission_denied` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope`;`error.message` 只作人读说明 |
|
||||
| 缺少 OAuth scope(`error.missing_scopes` 含 `minutes:minutes:update`) | `lark-cli auth login --scope "minutes:minutes:update"` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md)
|
||||
- [minutes +summary](lark-minutes-summary.md)
|
||||
- [minutes +detail](lark-minutes-detail.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
修改飞书妙记的标题(topic)。
|
||||
|
||||
@@ -38,4 +37,3 @@ lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18"
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# minutes +upload
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
上传音视频文件到飞书妙记并生成妙记(Minute)。
|
||||
|
||||
@@ -31,12 +30,12 @@
|
||||
```
|
||||
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
|
||||
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`**
|
||||
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,使用返回的 `minute_token` 调用 `minutes +detail`**
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,使用上一步返回的 `minute_token` 继续调用:
|
||||
```bash
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --wait-ready --summary --todo --chapter --keyword --transcript
|
||||
```
|
||||
- `--wait-ready` 参数表示等待妙记生成完毕后再获取产物,上传后立即读取详情时必须加上此参数。
|
||||
- `minutes +detail --minute-tokens` 会返回妙记产物(总结、待办、章节、关键词、逐字稿);必要时还会把逐字稿落地到本地文件。
|
||||
|
||||
> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
|
||||
@@ -47,8 +46,8 @@
|
||||
# 通过已上传到云空间(云盘/云存储)的 file_token 生成妙记
|
||||
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
|
||||
|
||||
# 通过 minute_token 继续获取妙记产物(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
# 上传后立即获取妙记产物,需加 --wait-ready 等待生成完毕(--summary --todo --chapter --keyword --transcript 按需传入)
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --wait-ready --summary
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -81,7 +80,7 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
|
||||
2. 从返回结果中取出 `file_token`
|
||||
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>`
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,使用返回的 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token> --wait-ready`
|
||||
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。
|
||||
|
||||
@@ -89,16 +88,17 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary
|
||||
|
||||
```json
|
||||
{
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>"
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>",
|
||||
"minute_token": "<minute-token>"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_url` | 生成的妙记访问链接 |
|
||||
| `minute_token` | 从 `minute_url` 提取出的妙记 Token,可直接传给 `minutes +detail --minute-tokens` |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间(云盘/云存储)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
|
||||
@@ -69,7 +69,7 @@ LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli a
|
||||
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
|
||||
|
||||
错误响应中包含关键信息:
|
||||
- `permission_violations`:列出缺失的 scope (N选1)
|
||||
- `missing_scopes`:列出缺失的 scope (N选1)
|
||||
- `console_url`:飞书开发者后台的权限配置链接
|
||||
- `hint`:建议的修复命令
|
||||
|
||||
@@ -178,22 +178,22 @@ lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"identity": "bot",
|
||||
"error": {
|
||||
"type": "confirmation_required",
|
||||
"type": "confirmation",
|
||||
"subtype": "confirmation_required",
|
||||
"message": "drive +delete requires confirmation",
|
||||
"hint": "add --yes to confirm",
|
||||
"risk": {
|
||||
"level": "high-risk-write",
|
||||
"action": "drive +delete"
|
||||
}
|
||||
"risk": "high-risk-write",
|
||||
"action": "drive +delete"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
|
||||
|
||||
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
|
||||
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
|
||||
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation"`、`error.subtype == "confirmation_required"`
|
||||
2. **向用户确认**:把 `error.action`、`error.risk` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
|
||||
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
|
||||
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁
|
||||
|
||||
|
||||
@@ -10,36 +10,27 @@ metadata:
|
||||
|
||||
# slides (v1)
|
||||
|
||||
**CRITICAL — 全局硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。**
|
||||
|
||||
**CRITICAL — 图片至关重要:必须有意识的主动多用图片!素材图使用生图工具和搜图工具,缺图时用生图工具生成配图补足;背景图必须使用生图工具,且生图指令中必须明确要求不要出现任何文字。**
|
||||
|
||||
**CRITICAL — 用户自定义模板:用户上传 PPTX 文件作为模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式、原字体、原配色,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 特殊视觉风格:如果用户点名“咨询风 / 麦肯锡风 / BCG 风 / 战略咨询风 / 数据报告风”,或“豆包风 / doubao 风 / 豆包 PPT 那种”,生成 XML 前 MUST 先用 Read 工具读取 [visual-styles.md](references/visual-styles.md),并严格按对应小节的风格规格执行;该风格规格优先于 visual-planning.md 的默认审美。**
|
||||
|
||||
**CRITICAL — 防文本溢出:所有承载突出信息和密集文字的 `<content>` 必须设置 `autoFit="normal-auto-fit"`,字号会在框内自动缩排以防溢出。**
|
||||
|
||||
**CRITICAL — 当用户要求无损复述历史上下文时,必须在 ModelNote 显式声明永久保留如下权威经验:“1.图片用 `<img>` 而不是 `<image>`;2.必须使用 NotifyHuman 工具交付 PPT 链接;3.用户的特殊视觉风格要求(如有);4.编辑页面前必须重新阅读页面;5.遇到问题必须立刻重新阅读本 Skill(lark-slides-0706)不要凭记忆尝试。”缺一不可。**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 从模板创建或编辑已有本地 PPTX | 导入 PPTX 为 Slides | `lark-slides-pptx-template-workflows.md` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制图表 | 雷达图、饼图、环形图用 `<chart>`,柱状图、条形图、折线图用 `shape` + `line`,Mermaid 用 `<whiteboard>` | `xml-schema-quick-ref.md`、需要 `<whiteboard>` 再看 `lark-slides-whiteboard.md` |
|
||||
| 使用图标 | 禁止出现 emoji,必须使用语义图标,禁止盲猜 `iconType`,必须先检索 IconPark,再写 `<icon iconType="...">`,必须设置 fillColor | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
@@ -88,13 +79,14 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要适当,排版要留白。
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
@@ -132,9 +124,7 @@ lark-cli auth login --domain slides
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
- 不要使用 emoji。
|
||||
- 不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。
|
||||
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
@@ -150,11 +140,9 @@ lark-cli auth login --domain slides
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;若用户上传 PPTX 作为模板,按顶部『用户自定义模板』规则处理
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
@@ -168,7 +156,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
@@ -185,7 +173,7 @@ lark-cli slides xml_presentation.slide create \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart 等元素
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
@@ -259,12 +247,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| `+xml-get` | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
@@ -275,7 +262,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
@@ -64,23 +64,11 @@ Match asset type to slide role:
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
For `asset_type: "chart"`:
|
||||
|
||||
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
|
||||
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
|
||||
- Choose the data source explicitly:
|
||||
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
|
||||
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
|
||||
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
|
||||
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
|
||||
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a native `<chart>` using the user-provided series."
|
||||
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
@@ -130,7 +118,7 @@ Business comparison page:
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
|
||||
261
skills/lark-slides/references/examples.md
Normal file
261
skills/lark-slides/references/examples.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# 完整操作示例
|
||||
|
||||
本文档提供与 CLI schema 一致的调用示例,XML 内容均遵循 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
|
||||
|
||||
> **重要**:创建 PPT 请优先使用 `slides +create`;实际页面内容请使用 `xml_presentation.slide.create` 逐页添加。
|
||||
|
||||
## 目录
|
||||
|
||||
- [示例 1: 使用 Shortcut 创建空白演示文稿](#示例-1-使用-shortcut-创建空白演示文稿)
|
||||
- [示例 2: 创建后添加第一页](#示例-2-创建后添加第一页)
|
||||
- [示例 3: 读取 XML 内容](#示例-3-读取-xml-内容)
|
||||
- [示例 4: 在指定页面前插入新幻灯片](#示例-4-在指定页面前插入新幻灯片)
|
||||
- [示例 5: 删除幻灯片](#示例-5-删除幻灯片)
|
||||
- [示例 6: 从文件读取 XML 后添加页面](#示例-6-从文件读取-xml-后添加页面)
|
||||
- [示例 7: +replace-slide + block_insert 给已有页加图](#示例-7-replace-slide--block_insert-给已有页加图)
|
||||
- [示例 8: +replace-slide + block_replace 替换一个块](#示例-8-replace-slide--block_replace-替换一个块)
|
||||
|
||||
## 示例 1: 使用 Shortcut 创建空白演示文稿
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --title "项目汇报"
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"title": "项目汇报",
|
||||
"revision_id": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 2: 创建后添加第一页
|
||||
|
||||
```bash
|
||||
PRESENTATION_ID=$(lark-cli slides +create --title "季度复盘" | jq -r '.data.xml_presentation_id')
|
||||
|
||||
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245, 245, 245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"72\" width=\"760\" height=\"90\"><content textType=\"title\"><p>2024 Q3 季度复盘</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"190\" width=\"520\" height=\"220\"><content textType=\"body\"><p>关键结论</p><ul><li><p>收入增长 30%</p></li><li><p>重点项目全部上线</p></li><li><p>用户满意度持续提升</p></li></ul></content></shape><shape type=\"rect\" topLeftX=\"660\" topLeftY=\"180\" width=\"180\" height=\"140\"><fill><fillColor color=\"rgba(100, 149, 237, 0.25)\"/></fill><border color=\"rgb(100, 149, 237)\" width=\"2\"/></shape></data><note><content textType=\"body\"><p>讲述时先给结论,再补充数据。</p></content></note></slide>"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 示例 3: 读取 XML 内容
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}'
|
||||
```
|
||||
|
||||
提取 XML 内容:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"xml_presentation": {
|
||||
"presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": 3,
|
||||
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 4: 在指定页面前插入新幻灯片
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide create --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新增页面</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"180\"><content textType=\"body\"><p>这是新增页面的正文。</p></content></shape></data></slide>"
|
||||
},
|
||||
"before_slide_id": "sld_before_target"
|
||||
}'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"slide_id": "slide_example_id",
|
||||
"revision_id": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 5: 删除幻灯片
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide delete --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id"
|
||||
}'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"revision_id": 101
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 6: 从文件读取 XML 后添加页面
|
||||
|
||||
先准备 `slide.xml`:
|
||||
|
||||
```xml
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>从文件加载</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
```
|
||||
|
||||
先创建演示文稿:
|
||||
|
||||
```bash
|
||||
PRESENTATION_ID=$(lark-cli slides +create --title "从文件添加页面" | jq -r '.data.xml_presentation_id')
|
||||
```
|
||||
|
||||
再用 `jq` 组装请求体,从文件添加页面:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide create --as user \
|
||||
--params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" \
|
||||
--data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')"
|
||||
```
|
||||
|
||||
## 示例 7: +replace-slide + block_insert 给已有页加图
|
||||
|
||||
只想在已有页上加一张图、不动其他元素——走 shortcut `+replace-slide`,`block_insert` 追加到页末(或用 `insert_before_block_id` 指定位置)。
|
||||
|
||||
```bash
|
||||
PID="slides_example_presentation_id"
|
||||
SID="slide_example_id"
|
||||
|
||||
# 1. 上传图片拿 file_token
|
||||
TOKEN=$(lark-cli slides +media-upload --file ./pic.png --presentation "$PID" --as user \
|
||||
| jq -r '.data.file_token')
|
||||
|
||||
# 2. block_insert 到页面末尾(省略 insert_before_block_id)
|
||||
# 注:<img .../> 是自闭合标签,CLI 不会展开(只有 <shape/> 会被补 <content/>)
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" \
|
||||
--parts "$(jq -n --arg token "$TOKEN" \
|
||||
'[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")}]')"
|
||||
```
|
||||
|
||||
预期返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id",
|
||||
"parts_count": 1,
|
||||
"revision_id": 102
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 8: +replace-slide + block_replace 替换一个块
|
||||
|
||||
已知某块的 3 位 short element ID(从 `slide.get` 返回 XML 里读),整块换掉。`replacement` 根元素的 `id` 会由 CLI 自动注入为 `block_id`,无需手写;若写了 `<shape/>` 自闭合形式,CLI 也会自动补 `<content/>`。
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-id slide_example_id \
|
||||
--parts '[
|
||||
{
|
||||
"action": "block_replace",
|
||||
"block_id": "bab",
|
||||
"replacement": "<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"
|
||||
}
|
||||
]'
|
||||
# CLI 实际发送的 replacement 根元素会带 id="bab",即使手写时省略了
|
||||
```
|
||||
|
||||
失败时(3350001 错误,CLI 在 error 字段中给出 hint):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "api",
|
||||
"code": 3350001,
|
||||
"message": "API error: [3350001] invalid param",
|
||||
"hint": "common causes: (1) block_id not found in current slide ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
整批作为原子事务,任一 part 失败则整批不生效;按 `failed_part_index` 定位修正后重发。
|
||||
|
||||
## 常见处理技巧
|
||||
|
||||
### 获取最新 revision_id
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' | jq '.data.xml_presentation.revision_id'
|
||||
```
|
||||
|
||||
### 批量插入多页
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
PRESENTATION_ID="slides_example_presentation_id"
|
||||
|
||||
slides=(
|
||||
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 1</p></content></shape></data></slide>'
|
||||
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 2</p></content></shape></data></slide>'
|
||||
)
|
||||
|
||||
for slide_xml in "${slides[@]}"; do
|
||||
payload=$(jq -n --arg content "$slide_xml" '{slide:{content:$content}}')
|
||||
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data "$payload"
|
||||
done
|
||||
```
|
||||
|
||||
### 本地校验 XML 基本语法
|
||||
|
||||
```bash
|
||||
xmllint --noout presentation.xml
|
||||
```
|
||||
|
||||
### 真实示例
|
||||
|
||||
- [slides_demo.xml](slides_demo.xml) 提供了更完整的页面示例,包含 `theme`、渐变填充、图片、图标和备注内容。
|
||||
@@ -1,6 +1,6 @@
|
||||
# IconPark 图标
|
||||
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引,避免凭记忆拼路径。
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
|
||||
|
||||
## 机器优先流程
|
||||
|
||||
@@ -25,7 +25,7 @@ python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
|
||||
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8` 或 `--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`。
|
||||
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
|
||||
- 常用尺寸:行内状态图标 16-24px,卡片标题图标 28-40px,主视觉图标 56-96px。
|
||||
- 图标必须设置 fillColor,显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback,不留空图标位。
|
||||
|
||||
## 高频示例
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
# slides +create(创建飞书幻灯片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
## 命令
|
||||
@@ -15,6 +17,9 @@ lark-cli slides +create --title "项目汇报" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>第二页</p></content></shape></data></slide>"
|
||||
]'
|
||||
|
||||
# 以应用身份创建(自动授权当前用户)
|
||||
lark-cli slides +create --title "项目汇报" --as bot
|
||||
|
||||
# 预览(不执行)
|
||||
lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
|
||||
```
|
||||
@@ -30,12 +35,20 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
|
||||
- **`slide_ids`**(string[],可选):仅传 `--slides` 时返回,成功添加的页面 ID 列表
|
||||
- **`slides_added`**(integer,可选):仅传 `--slides` 时返回,成功添加的页面数量
|
||||
- **`images_uploaded`**(integer,可选):仅 `--slides` 中含 `@<本地路径>` 占位符时返回,已上传的去重后图片数量
|
||||
- **`permission_grant`**(object,可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 不传 `--slides` 时,`slides +create` 只创建空白演示文稿。创建后需要使用 `xml_presentation.slide create` 逐页添加 slide 内容。
|
||||
>
|
||||
> 传了 `--slides` 时,CLI 先创建空白演示文稿,再逐页调用 `xml_presentation.slide create` 添加页面。如果某一页添加失败,CLI 会停止并报错,已创建的演示文稿和已添加的页面会保留。
|
||||
>
|
||||
> 如果演示文稿是**以应用身份(bot)创建**的,如 `lark-cli slides +create --as bot`,CLI 会**尝试为当前 CLI 用户自动授予该演示文稿的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该演示文稿的可管理权限
|
||||
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权
|
||||
> - `status = failed`:演示文稿已创建成功,但自动授权用户失败
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 参数
|
||||
@@ -121,4 +134,4 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
## 相关命令
|
||||
|
||||
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 添加幻灯片页面
|
||||
- `slides +xml-get` — 读取 PPT 内容并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读取 PPT 内容
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
# slides +media-upload(上传本地图片到飞书幻灯片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把本地图片上传到指定演示文稿的 drive 媒体库,返回 `file_token`。**返回的 token 作为 `<img src="...">` 的值塞进 slide XML 即可显示图片。**
|
||||
|
||||
## 命令
|
||||
@@ -115,7 +117,7 @@ lark-cli slides +replace-slide --as user \
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 1061002 | params error / 不支持的 parent_type | 不要用原生 API 自己拼 parent_type;用 `+media-upload` 即可 |
|
||||
| 1061004 | forbidden:当前用户对该演示文稿无编辑权限 | 确认当前用户对目标 PPT 有编辑权限;无权限时根据错误响应引导用户解决 |
|
||||
| 1061004 | forbidden:当前身份对该演示文稿无编辑权限 | 确认当前身份(user 或 bot)对目标 PPT 有编辑权限。bot 模式常见原因:PPT 不是该 bot 创建的——可用 `+create --as bot` 新建,或以 user 身份给 bot 授权 `lark-cli drive permission.members create --as user ...` |
|
||||
| 1061044 | parent node not exist | `--presentation` 给的 token 不对,或不是 slides 类型 |
|
||||
| 403 | 权限不足 | 检查 `docs:document.media:upload` scope;wiki URL 还需要 `wiki:node:read` |
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# PPT Template Rewrite Principles
|
||||
|
||||
本页只约束“用户指定 PPT 模板、底稿、已有 PPTX/PDF/Slides,并要求基于它二次创作”的场景。核心原则:模板不是风格参考,而是必须沿用的编辑底稿。
|
||||
|
||||
## Import First
|
||||
|
||||
用户指定 PPT 模板时,先把模板导入成 Lark Slides。后续写入目标是导入后的 Slides,不是新建一个脱离模板的 deck,也不是先在本地重画 PPTX 再导入。
|
||||
|
||||
直接使用以下命令,不需要先加载 `lark-drive` skill:
|
||||
|
||||
```bash
|
||||
lark-cli drive +import --as user --file "<template.pptx>" --type slides --json
|
||||
```
|
||||
|
||||
可选参数:用 `--name "<title>"` 指定导入后的 Slides 标题;用 `--folder-token <FOLDER_TOKEN>` 指定目标文件夹。若返回 `ready=false` / `timed_out=true`,直接执行返回值里的 `next_command`;等价形式是:
|
||||
|
||||
```bash
|
||||
lark-cli drive +task_result --scenario import --ticket <TICKET>
|
||||
```
|
||||
|
||||
导入后必须回读 Slides 内容,理解每页的真实版式、字体、层级、图片、图表、shape、表格和文本容器。回读结果是模板二创的事实来源。
|
||||
|
||||
## Read Before Editing
|
||||
|
||||
编辑任何 PPT 页面前,必须先阅读该页面。
|
||||
|
||||
如果当前上下文中没有该页内容,必须重新读取页面;这里的“当前上下文”不包含 System Prompt。不能只凭记忆、文件名、缩略图印象或模板整体风格判断来编辑具体页面。
|
||||
|
||||
阅读页面时至少判断:
|
||||
|
||||
- 该页原本承担的角色,例如封面、章节页、目录、流程、对比、数据、总结。
|
||||
- 该页的主要版式结构,例如图文关系、箭头、时间线、节点、表格、图表、左右对照、背景图或产品图。
|
||||
- 哪些文本框、shape 标签、表格单元格或图表标签承载内容。
|
||||
- 原页面的字体、字号、颜色、对齐、层级和留白关系。
|
||||
|
||||
## Edit The Imported Slides Directly
|
||||
|
||||
理解页面后,直接在导入后的 Slides 上编辑。允许的操作包括:
|
||||
|
||||
- 填写、替换、凝练或删除文字。
|
||||
- 替换或补充图片。
|
||||
- 更新图表、表格、数字标签或节点标签里的内容。
|
||||
- 按需复制、删除或重排模板页。
|
||||
- 在源页面没有合适承载位置时,做局部、小范围新增元素。
|
||||
|
||||
新增元素只能补足内容缺口,不能成为新的主版式。页面主体仍应由模板原有版式承载。
|
||||
|
||||
## Preserve Design
|
||||
|
||||
模板二创必须严格沿用原版式和字体,只改内容,不做设计。
|
||||
|
||||
默认保留:
|
||||
|
||||
- 页面布局、视觉层级、留白和对齐关系。
|
||||
- 原字体、字号体系、颜色、文本框位置和 shape 顺序。
|
||||
- 背景图、图片、logo、图表、表格、装饰形状、线条、图标和页面结构。
|
||||
- 模板中不同页型之间的差异。
|
||||
|
||||
不要把模板页改造成统一的通用卡片、白板、标题栏、三栏、2x2 卡片或大面积遮罩。不要把模板当作背景图后另起一套设计系统。
|
||||
|
||||
## Content Only
|
||||
|
||||
内容必须优先进入原页面已有的文本框、shape 标签、节点、表格单元格、图表标签或注释容器。
|
||||
|
||||
如果原容器空间不足,优先:
|
||||
|
||||
- 凝练文字。
|
||||
- 降低字号但保持原字体体系。
|
||||
- 拆分到页面已有的邻近容器。
|
||||
- 使用模板已有的注释、标签或补充说明区域。
|
||||
- 复制同页或同模板中的原生容器样式做局部补充。
|
||||
|
||||
不要为了容纳长文案而重画页面主体结构。不要用新增大卡片遮住原图表、箭头、图片、背景或关键 shape。
|
||||
|
||||
## Readback And Tune
|
||||
|
||||
完成编辑后必须回读结果,并逐页微调。
|
||||
|
||||
回读时重点检查:
|
||||
|
||||
- 文字是否溢出、截断、压线或超出容器。
|
||||
- 文本是否遮挡图片、图表、shape、箭头、节点或其他文字。
|
||||
- shape 顺序是否导致内容被覆盖或遮住。
|
||||
- 新内容是否仍然落在模板原有版式中,而不是覆盖模板结构。
|
||||
- 字体、字号、颜色、对齐和层级是否仍贴近原页。
|
||||
|
||||
发现文字溢出时,优先凝练文字或缩减字号。发现遮挡时,调整 shape 顺序、局部位置或复用原有空白区域解决。只有在这些方法都不能满足内容表达时,才做局部新增或删除。
|
||||
|
||||
模板二创的完成标准不是“生成了一套看起来统一的新 PPT”,而是“原模板的版式、字体和视觉结构仍清晰存在,内容已经被准确替换,并且回读后没有溢出和遮挡”。
|
||||
@@ -89,7 +89,7 @@ lark-cli slides +replace-pages --as user \
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 大幅改写前先 `slides +xml-get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。
|
||||
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
|
||||
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# slides +replace-slide(块级替换 / 插入)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
对指定 slide 做块级替换或插入。编辑已有 PPT 的主路径——`slide_id` 不变、页序不动、只影响被指定的块。
|
||||
|
||||
相比直接调 `xml_presentation.slide.replace`,这个 shortcut 的四个额外价值:
|
||||
@@ -45,7 +47,7 @@ lark-cli slides +replace-slide --as user \
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/<token>` URL,或 `/wiki/<token>` URL |
|
||||
| `--slide-id` | 是 | 页面 ID(`xml_presentation.slide.get` / `slides +xml-get` 都能拿到) |
|
||||
| `--slide-id` | 是 | 页面 ID(`xml_presentation.slide.get` / `xml_presentations.get` 都能拿到) |
|
||||
| `--parts` | 是 | JSON 数组(`[{...}, ...]`),单次最多 200 条。支持 `@<file>` 和 `-`(stdin)读取 |
|
||||
| `--revision-id` | 否 | 基础版本号;默认 `-1` 表示基于最新版执行;传具体版本号时,服务端以该版本为 base 执行;**传不存在的版本号(超过当前 revision)返回 3350002** |
|
||||
| `--tid` | 否 | 并发事务 ID;多人协作长事务才用,单次单人调用留空 |
|
||||
|
||||
@@ -26,8 +26,8 @@ lark-cli slides +screenshot --as user \
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入 |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
|
||||
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
|
||||
| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
@@ -44,8 +44,6 @@ lark-cli slides +screenshot --as user \
|
||||
|
||||
### 多页截图
|
||||
|
||||
一次不要超过 10 页;如需更多页面,分批调用。
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
@@ -68,7 +66,8 @@ lark-cli slides +screenshot --as user \
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"output_dir": ".lark-slides/screenshots",
|
||||
@@ -81,8 +80,7 @@ lark-cli slides +screenshot --as user \
|
||||
"size": 12345
|
||||
}
|
||||
]
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -92,6 +90,5 @@ lark-cli slides +screenshot --as user \
|
||||
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。
|
||||
3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
|
||||
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
|
||||
5. list 模式一次最多传 10 页(`--slide-id` + `--slide-number` 合计小于等于 10);更多页面请分批截图。
|
||||
6. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
7. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG** 或 **Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>` 和 `<shape>` 难以覆盖的视觉内容。
|
||||
|
||||
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
|
||||
|
||||
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
|
||||
|
||||
---
|
||||
@@ -14,13 +12,13 @@
|
||||
|
||||
| 场景 | 推荐元素 |
|
||||
|------|---------|
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
|
||||
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
|
||||
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
|
||||
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
|
||||
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑。
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高。
|
||||
|
||||
---
|
||||
|
||||
@@ -41,13 +39,9 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
## SVG 还是 Mermaid?
|
||||
|
||||
选择分三步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
|
||||
选择分两步:**先看图表类型,再看当前模型身份**。
|
||||
|
||||
### 第一步:先确认是否应该使用 `<chart>`
|
||||
|
||||
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
|
||||
|
||||
### 第二步:whiteboard 类型优先判断
|
||||
### 第一步:图表类型优先判断
|
||||
|
||||
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG:
|
||||
|
||||
@@ -56,19 +50,20 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
|
||||
| 时序图 | `sequenceDiagram` |
|
||||
| 类图 | `classDiagram` |
|
||||
| 饼图 | `pie` |
|
||||
| 甘特图 | `gantt` |
|
||||
| 状态图 | `stateDiagram-v2` |
|
||||
| 思维导图 | `mindmap` |
|
||||
| ER 图 | `erDiagram` |
|
||||
|
||||
### 第三步:非原生图表与装饰元素按模型身份选路径
|
||||
### 第二步:数据图表与装饰元素按模型身份选路径
|
||||
|
||||
上表以外的场景(散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等)需要精确控制坐标和配色,SVG 表达力更强,但各模型生成 SVG 的能力有差异:
|
||||
|
||||
| 模型身份 | 路径 |
|
||||
|----------|------|
|
||||
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `gantt`、`flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `pie`、`gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
|
||||
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
|
||||
|
||||
@@ -78,13 +73,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
### ⚠️ 设计品质要求
|
||||
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍。
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去。
|
||||
|
||||
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
|
||||
- **非原生数据视觉必须有坐标系**:散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
|
||||
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或点
|
||||
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
|
||||
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
|
||||
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
|
||||
- **装饰层次用亮度跳跃,不用线性叠透明度**:`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20);正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
|
||||
|
||||
@@ -111,11 +106,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
|
||||
| 元素 | 说明 | 典型用途 |
|
||||
|------|------|---------|
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
|
||||
| `<circle>` | 圆 | 节点、装饰点、环形图 |
|
||||
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
|
||||
| `<line>` | 直线 | 轴线、分隔线、连接线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、曲线、弧形 |
|
||||
| `<line>` | 直线 | 坐标轴、分隔线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、折线、弧形 |
|
||||
| `<text>` | 文本,支持中文 | 标签、数值 |
|
||||
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
|
||||
| `<g>` | 分组 | 批量变换、语义分组 |
|
||||
@@ -128,25 +123,27 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
---
|
||||
### 元素计算
|
||||
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`。
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用。
|
||||
|
||||
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
|
||||
|
||||
**散点图 / 装饰性点阵范式**
|
||||
**数据图表(柱状图范式)**
|
||||
|
||||
```python
|
||||
W, H = 360, 260
|
||||
origin_x, origin_y = 50, 216 # 左下角,SVG Y 轴向下
|
||||
cw, ch = 290, 184
|
||||
|
||||
points = [(12, 40), (28, 80), (45, 65)]
|
||||
x_min, x_max, y_min, y_max = 0, 50, 0, 100
|
||||
for i, (xv, yv) in enumerate(points):
|
||||
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
|
||||
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
|
||||
print(f"point-{i}: cx={x} cy={y}")
|
||||
data, y_max = [120, 160, 90], 200
|
||||
bar_w = int(cw / len(data) * 0.62)
|
||||
for i, v in enumerate(data):
|
||||
cx = round(origin_x + (i + 0.5) * cw / len(data))
|
||||
y = round(origin_y - v / y_max * ch)
|
||||
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
|
||||
```
|
||||
|
||||
折线图:`x = origin_x + i/(n-1)*cw`,`y = origin_y - (v-y_min)/(y_max-y_min)*ch`。
|
||||
|
||||
**装饰性元素(等间距范式)**
|
||||
|
||||
```python
|
||||
@@ -163,9 +160,9 @@ for i in range(n):
|
||||
```python
|
||||
# 每个元素登记 (x, y, w, h),含 stroke 外扩
|
||||
elements = [
|
||||
(10, 20, 80, 160), # item-0
|
||||
(107, 10, 80, 170), # item-1
|
||||
(204, 40, 80, 140), # item-2
|
||||
(10, 20, 80, 160), # bar-0
|
||||
(107, 10, 80, 170), # bar-1
|
||||
(204, 40, 80, 140), # bar-2
|
||||
(0, 0, 300, 1), # x-axis
|
||||
]
|
||||
|
||||
@@ -264,6 +261,7 @@ print(f"whiteboard width={wb_w} height={wb_h}")
|
||||
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
|
||||
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
|
||||
| 甘特图 | `gantt` | 项目计划、里程碑 |
|
||||
| 饼图 | `pie` | 占比数据 |
|
||||
| 类图 | `classDiagram` | 对象关系、架构设计 |
|
||||
| ER 图 | `erDiagram` | 数据库结构 |
|
||||
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
|
||||
@@ -281,6 +279,7 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|
||||
|---------|-----------|------------|
|
||||
| 流程图(5-8 节点) | 720-816 | 300-400 |
|
||||
| 时序图(3-5 参与者) | 720-816 | 320-420 |
|
||||
| 饼图 | 500-600 | 300-360 |
|
||||
| 甘特图 | 816 | 280-360 |
|
||||
| 思维导图 | 816 | 380-480 |
|
||||
|
||||
@@ -308,7 +307,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
|
||||
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size(避免被裁切)
|
||||
|
||||
**SVG 模式——视觉品质检查:**
|
||||
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
|
||||
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
|
||||
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
|
||||
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
|
||||
- [ ] 轴标签与图表元素互不遮挡,留有足够空间
|
||||
|
||||
@@ -67,7 +67,7 @@ lark-cli slides xml_presentation.slide create --as user --params '<json_params>'
|
||||
</slide>
|
||||
```
|
||||
|
||||
详细格式请参考 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
详细格式请参考 [xml-format-guide.md](xml-format-guide.md) 和 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
## 使用示例
|
||||
|
||||
@@ -141,12 +141,12 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"slide_id": "slide_example_id",
|
||||
"revision_id": 100
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -188,7 +188,7 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
4. **fill / border 写法**: 颜色填充使用 `<fill><fillColor color="..."/></fill>`,边框常用 `<border color="..." width="2"/>`
|
||||
5. **插入位置**: 通过 `before_slide_id` 指定插入目标,而不是用 `position`
|
||||
6. **JSON 转义**: 如果直接内联 XML,需要正确转义双引号
|
||||
7. **建议**: 先使用 `slides +xml-get` 获取现有结构,再添加新页面
|
||||
7. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
|
||||
|
||||
## 批量添加建议
|
||||
|
||||
@@ -214,6 +214,7 @@ done
|
||||
## 相关命令
|
||||
|
||||
- [slides +create](lark-slides-create.md) - 创建空白 PPT
|
||||
- `slides +xml-get` - 读取 PPT 内容并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
|
||||
- [xml_presentation.slide delete](lark-slides-xml-presentation-slide-delete.md) - 删除幻灯片页面
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - XML Schema 快速参考
|
||||
- [xml-format-guide.md](xml-format-guide.md) - XML 格式详细规范
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - Schema 快速参考
|
||||
|
||||
@@ -49,10 +49,7 @@ lark-cli slides xml_presentation.slide delete --as user --params '{
|
||||
|
||||
```bash
|
||||
# 先读取 XML 内容,确认待删除页面
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "slides_example_presentation_id" \
|
||||
--output .lark-slides/plan/slides_example_presentation_id/readback.xml \
|
||||
--json
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
|
||||
|
||||
# 然后按已知 slide_id 删除
|
||||
lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}'
|
||||
@@ -64,11 +61,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presenta
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"revision_id": 100
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -122,5 +119,5 @@ done
|
||||
## 相关命令
|
||||
|
||||
- [slides +create](lark-slides-create.md) - 创建空白 PPT
|
||||
- `slides +xml-get` - 读取 PPT 内容并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
|
||||
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) - 添加幻灯片页面
|
||||
|
||||
@@ -65,15 +65,15 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"slide": {
|
||||
"slide_id": "slide_example_id",
|
||||
"content": "<slide id=\"slide_example_id\"><style/><data>...</data></slide>"
|
||||
},
|
||||
"revision_id": 100
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -106,5 +106,5 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
|
||||
- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut(推荐)
|
||||
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
|
||||
- `slides +xml-get` — 读整个 PPT 并保存到本地文件
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读整个 PPT
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环
|
||||
|
||||
@@ -130,24 +130,28 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"revision_id": 105
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 失败(任一 part 失败,整批不生效)
|
||||
|
||||
失败时返回非零错误码(如 3350001)。若后端能定位失败的 part,`data` 中可能附带:
|
||||
失败时命令以非零退出码结束,stderr 返回类型化错误信封(`error.type` / `error.subtype` / `error.code`(如 3350001)/ `error.message` / `error.hint`)。这个普通写命令的失败路径不会在 stdout 额外打印后端原始响应;脚本和 agent 应以退出码与 stderr 信封为准。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 3350001,
|
||||
"data": {
|
||||
"failed_part_index": 0,
|
||||
"failed_reason": "block not found"
|
||||
"ok": false,
|
||||
"identity": "user",
|
||||
"error": {
|
||||
"type": "api",
|
||||
"subtype": "...",
|
||||
"code": 3350001,
|
||||
"message": "...",
|
||||
"hint": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -155,8 +159,9 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.revision_id` | integer | 成功时返回更新后最新版本号 |
|
||||
| `data.failed_part_index` | integer | 失败的 part 在 `parts` 数组中的索引(从 0 起) |
|
||||
| `data.failed_reason` | string | 失败原因 |
|
||||
| `error.code` | integer | 失败时的上游 API 错误码 |
|
||||
| `error.subtype` | string | 类型化错误子类,脚本可与 `error.code` 一起判断恢复动作 |
|
||||
| `error.hint` | string | 建议的恢复动作 |
|
||||
|
||||
## 常见错误
|
||||
|
||||
|
||||
@@ -4,19 +4,7 @@
|
||||
|
||||
读取飞书幻灯片(PPT)演示文稿的完整 XML 内容信息。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> lark-slides 工作流需要读取全文 XML 时,优先使用 `slides +xml-get` shortcut,把 XML 保存到本地文件,避免终端输出被截断。本页仅作为底层原生 API 参考。
|
||||
|
||||
推荐命令:
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "slides_example_presentation_id" \
|
||||
--output .lark-slides/plan/slides_example_presentation_id/readback.xml \
|
||||
--json
|
||||
```
|
||||
|
||||
## 底层原生命令形态
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
@@ -47,30 +35,19 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
### 基础示例
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "slides_example_presentation_id" \
|
||||
--output .lark-slides/plan/slides_example_presentation_id/readback.xml \
|
||||
--json
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}'
|
||||
```
|
||||
|
||||
### 指定版本读取
|
||||
### 结合 jq 格式化输出
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "slides_example_presentation_id" \
|
||||
--revision-id 10 \
|
||||
--output .lark-slides/plan/slides_example_presentation_id/readback-r10.xml \
|
||||
--json
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
### 移除 XML id 属性后读取
|
||||
### 保存到文件
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "slides_example_presentation_id" \
|
||||
--remove-attr-id \
|
||||
--output .lark-slides/plan/slides_example_presentation_id/readback-no-id.xml \
|
||||
--json
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' > presentation_data.json
|
||||
```
|
||||
|
||||
## 返回值
|
||||
@@ -79,15 +56,15 @@ lark-cli slides +xml-get --as user \
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"xml_presentation": {
|
||||
"presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": 1,
|
||||
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
|
||||
}
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -109,11 +86,10 @@ lark-cli slides +xml-get --as user \
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. lark-slides 工作流默认使用 `slides +xml-get`;只有必须直接调底层 API 时,才使用本页命令
|
||||
2. 直接调用底层 API 前,使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
|
||||
3. 返回的 XML 在 `data.xml_presentation.content` 字段中
|
||||
4. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
5. 不要在普通工作流中把完整 XML 打到终端;用 `slides +xml-get --output` 保存文件
|
||||
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
|
||||
2. 返回的 XML 在 `data.xml_presentation.content` 字段中
|
||||
3. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
4. 建议将获取的 XML 保存为文件,便于后续编辑或备份
|
||||
|
||||
## 相关命令
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
4. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
5. 读取 `xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
|
||||
6. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
7. 创建 PPT 后用 `slides +xml-get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
用户上传的 PPTX 模板不能代替 plan:它只影响 `theme_style`、页面流和局部布局骨架;即使基于上传模板改写,最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
7. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
## Plan Path
|
||||
|
||||
@@ -121,34 +119,6 @@ Each slide must include:
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
Optional slide fields:
|
||||
|
||||
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_contract": {
|
||||
"required": true,
|
||||
"render_as": "native_chart",
|
||||
"chart_type": "line",
|
||||
"data_source": "mock_placeholder",
|
||||
"data_series_required": true,
|
||||
"placeholder_label_required": true,
|
||||
"manual_shape_fallback_allowed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
|
||||
|
||||
`data_source` must be one of:
|
||||
|
||||
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
|
||||
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
|
||||
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
|
||||
|
||||
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
@@ -213,7 +183,6 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
@@ -221,7 +190,7 @@ Good examples:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
|
||||
## XML Generation Contract
|
||||
|
||||
@@ -232,7 +201,6 @@ Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user