mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
Compare commits
12 Commits
sun/lark-d
...
feat-ppe-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c19b83f0 | ||
|
|
85c07a990e | ||
|
|
a617d168c4 | ||
|
|
d7cd8e1b37 | ||
|
|
0e98163562 | ||
|
|
e3e5a0b723 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef |
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 {
|
||||
|
||||
10
dev-plans/lark-slides/README.md
Normal file
10
dev-plans/lark-slides/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Lark Slides Development Plans
|
||||
|
||||
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
|
||||
|
||||
约束:
|
||||
|
||||
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
|
||||
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
|
||||
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
|
||||
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。
|
||||
1612
dev-plans/lark-slides/create-svglide-implementation-plan.zh.md
Normal file
1612
dev-plans/lark-slides/create-svglide-implementation-plan.zh.md
Normal file
File diff suppressed because it is too large
Load Diff
1475
dev-plans/lark-slides/svg-slides-local-generation-action-plan.zh.md
Normal file
1475
dev-plans/lark-slides/svg-slides-local-generation-action-plan.zh.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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") }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,17 @@ const (
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
|
||||
whiteboardTag = "whiteboard"
|
||||
whiteboardTypeAttr = "type"
|
||||
whiteboardPathAttr = "path"
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
type whiteboardStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
|
||||
if !strings.Contains(content, "<whiteboard") {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteWhiteboardFileRefs(runtime, segment)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return rewrite(content)
|
||||
}
|
||||
|
||||
var rewriteErrs []error
|
||||
out := applyOutsideCodeFences(content, func(segment string) string {
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
rewriteErrs = append(rewriteErrs, rewriteErr)
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
|
||||
var rewriteErrs []error
|
||||
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
|
||||
if err != nil {
|
||||
rewriteErrs = append(rewriteErrs, err)
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
|
||||
startRaw, body, _, ok := splitWhiteboardElement(raw)
|
||||
if !ok {
|
||||
return raw, nil
|
||||
}
|
||||
tag, err := parseWhiteboardStartTag(startRaw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(whiteboardPathAttr)
|
||||
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
|
||||
if !hasPath && !hasBodyPath {
|
||||
return raw, nil
|
||||
}
|
||||
if hasPath && strings.TrimSpace(body) != "" {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
|
||||
}
|
||||
if hasPath && hasBodyPath {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
|
||||
}
|
||||
|
||||
typRaw, ok := tag.attr(whiteboardTypeAttr)
|
||||
if !ok || strings.TrimSpace(typRaw) == "" {
|
||||
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
|
||||
}
|
||||
typ, ok := canonicalWhiteboardFileType(typRaw)
|
||||
if !ok {
|
||||
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
|
||||
}
|
||||
|
||||
if hasBodyPath {
|
||||
pathValue = bodyPath
|
||||
}
|
||||
data, err := readWhiteboardPath(runtime, pathValue, typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag.setAttr(whiteboardTypeAttr, typ)
|
||||
tag.removeAttrs(whiteboardPathAttr)
|
||||
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
|
||||
}
|
||||
|
||||
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing = strings.HasSuffix(trimmed, "/>")
|
||||
if selfClosing {
|
||||
return raw, "", true, true
|
||||
}
|
||||
startEnd := strings.Index(raw, ">")
|
||||
if startEnd < 0 {
|
||||
return "", "", false, false
|
||||
}
|
||||
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
|
||||
if endStart < 0 || endStart < startEnd {
|
||||
return "", "", false, false
|
||||
}
|
||||
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
|
||||
}
|
||||
|
||||
func whiteboardBodyPathRef(body string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func canonicalWhiteboardFileType(raw string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "svg":
|
||||
return "svg", true
|
||||
case "mermaid":
|
||||
return "mermaid", true
|
||||
case "plantuml":
|
||||
return "plantuml", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
|
||||
}
|
||||
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
|
||||
WithParam("path").
|
||||
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
|
||||
WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func whiteboardExtAllowed(typ string, ext string) bool {
|
||||
for _, allowed := range whiteboardAllowedExts(typ) {
|
||||
if ext == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func whiteboardAllowedExts(typ string) []string {
|
||||
switch typ {
|
||||
case "svg":
|
||||
return []string{".svg"}
|
||||
case "mermaid":
|
||||
return []string{".mermaid", ".mmd"}
|
||||
case "plantuml":
|
||||
return []string{".plantuml", ".puml", ".pu", ".uml"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func whiteboardExtList(typ string) string {
|
||||
return strings.Join(whiteboardAllowedExts(typ), ", ")
|
||||
}
|
||||
|
||||
func exampleWhiteboardExt(typ string) string {
|
||||
exts := whiteboardAllowedExts(typ)
|
||||
if len(exts) == 0 {
|
||||
return "txt"
|
||||
}
|
||||
return strings.TrimPrefix(exts[0], ".")
|
||||
}
|
||||
|
||||
func whiteboardContentForType(typ string, data string) string {
|
||||
if typ == "svg" {
|
||||
return data
|
||||
}
|
||||
return escapeXMLText(data)
|
||||
}
|
||||
|
||||
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
|
||||
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
|
||||
messages := make([]string, 0, len(flatErrs))
|
||||
params := make([]errs.InvalidParam, 0, len(flatErrs))
|
||||
for _, err := range flatErrs {
|
||||
messages = append(messages, err.Error())
|
||||
params = append(params, whiteboardInvalidParamsFromError(err)...)
|
||||
}
|
||||
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
|
||||
WithParam("whiteboard").
|
||||
WithCause(errors.Join(flatErrs...))
|
||||
if len(params) > 0 {
|
||||
validationErr.WithParams(params...)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
|
||||
flatErrs := make([]error, 0, len(rewriteErrs))
|
||||
for _, err := range rewriteErrs {
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
|
||||
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
|
||||
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
flatErrs = append(flatErrs, err)
|
||||
}
|
||||
return flatErrs
|
||||
}
|
||||
|
||||
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return nil
|
||||
}
|
||||
if len(validationErr.Params) > 0 {
|
||||
return validationErr.Params
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return whiteboardStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != whiteboardTag {
|
||||
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) setAttr(name string, value string) {
|
||||
for i, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
t.Attrs[i].Value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(whiteboardTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ package doc
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
@@ -116,6 +118,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
files := map[string]string{
|
||||
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
|
||||
"flow.mmd": "flowchart TD\nA --> B",
|
||||
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
got := body["content"].(string)
|
||||
for _, want := range []string{
|
||||
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
|
||||
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
|
||||
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("content missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `path="@`) {
|
||||
t.Fatalf("content still contains whiteboard path attr: %s", got)
|
||||
}
|
||||
if _, ok := body["reference_map"]; ok {
|
||||
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
@@ -407,6 +464,119 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"missing.svg",
|
||||
"missing.mmd",
|
||||
"missing.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "missing.svg" cannot be read`,
|
||||
`whiteboard mermaid path "missing.mmd" cannot be read`,
|
||||
`whiteboard plantuml path "missing.puml" cannot be read`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"before.svg",
|
||||
"after.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "before.svg" cannot be read`,
|
||||
`whiteboard plantuml path "after.puml" cannot be read`,
|
||||
})
|
||||
if strings.Contains(err.Error(), "inside.svg") {
|
||||
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "whiteboard" {
|
||||
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
|
||||
}
|
||||
if validationErr.Cause == nil {
|
||||
t.Fatal("expected aggregated error to preserve cause")
|
||||
}
|
||||
var childValidationErr *errs.ValidationError
|
||||
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
|
||||
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
|
||||
}
|
||||
|
||||
gotParams := make(map[string]string, len(validationErr.Params))
|
||||
for _, param := range validationErr.Params {
|
||||
gotParams[param.Name] = param.Reason
|
||||
}
|
||||
if len(gotParams) != len(wantParams) {
|
||||
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for _, param := range wantParams {
|
||||
reason, ok := gotParams[param]
|
||||
if !ok {
|
||||
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
|
||||
}
|
||||
}
|
||||
for _, want := range wantMessages {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q:\n%v", want, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Cause.Error(), want) {
|
||||
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
||||
// original message as read after a reply/reply-all/forward operation.
|
||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
||||
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||
}
|
||||
|
||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||
|
||||
@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
||||
// TestHintMarkAsRead verifies hint mark as read.
|
||||
func TestHintMarkAsRead(t *testing.T) {
|
||||
rt, _, stderr := newOutputRuntime(t)
|
||||
// Inject ANSI escape + message ID to verify sanitization
|
||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
||||
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||
out := stderr.String()
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "msg-123") {
|
||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
||||
if strings.Contains(out, "\nnext") {
|
||||
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
482
shortcuts/mail/mail_message_manage_test.go
Normal file
482
shortcuts/mail/mail_message_manage_test.go
Normal file
@@ -0,0 +1,482 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func messageManageID(suffix string) string {
|
||||
return "msg_abcdefghijklmnop_" + suffix
|
||||
}
|
||||
|
||||
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||
t.Helper()
|
||||
success, ok := data["success_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||
}
|
||||
failed, ok := data["failed_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", param)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected failed precondition error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{""},
|
||||
{" id_with_leading_space_12345"},
|
||||
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||
{"1234567890123456"},
|
||||
{"short"},
|
||||
{"msg_abcdefghijklmnop!"},
|
||||
{"msg_abcdefghijklmnop\t"},
|
||||
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := normalizeMessageManageIDs(tc)
|
||||
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_Metadata(t *testing.T) {
|
||||
if MailMessageModify.Command != "+message-modify" {
|
||||
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||
}
|
||||
if MailMessageModify.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||
}
|
||||
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||
}
|
||||
requiredScopes := map[string]bool{
|
||||
"mail:user_mailbox.message:modify": true,
|
||||
}
|
||||
for _, scope := range MailMessageModify.Scopes {
|
||||
delete(requiredScopes, scope)
|
||||
}
|
||||
if len(requiredScopes) != 0 {
|
||||
t.Errorf("Scopes missing %v", requiredScopes)
|
||||
}
|
||||
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||
}
|
||||
flags := map[string]common.Flag{}
|
||||
for _, fl := range MailMessageModify.Flags {
|
||||
flags[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||
if _, ok := flags[name]; !ok {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_Metadata(t *testing.T) {
|
||||
if MailMessageTrash.Command != "+message-trash" {
|
||||
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||
}
|
||||
if MailMessageTrash.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||
}
|
||||
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||
}
|
||||
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||
if token == nil {
|
||||
t.Fatal("expected test token")
|
||||
}
|
||||
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "read_receipt_request",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread,customA",
|
||||
"--remove-label-ids", "FLAGGED",
|
||||
"--add-folder", "folderA",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := body["add_folder"]; got != "folderA" {
|
||||
t.Errorf("add_folder = %v, want folderA", got)
|
||||
}
|
||||
addLabels := body["add_label_ids"].([]interface{})
|
||||
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if removeLabels[0] != "FLAGGED" {
|
||||
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread",
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||
t.Fatalf("error = %v, want label intersection validation", err)
|
||||
}
|
||||
|
||||
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "trash",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||
t.Fatalf("error = %v, want TRASH validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 41)
|
||||
for i := range ids {
|
||||
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||
}
|
||||
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", strings.Join(ids, ","),
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||
}
|
||||
messageIDs := body["message_ids"].([]interface{})
|
||||
want := []int{20, 20, 1}[idx]
|
||||
if len(messageIDs) != want {
|
||||
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||
}
|
||||
if body["add_folder"] != "ARCHIVED" {
|
||||
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||
}
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 21 || len(failed) != 20 {
|
||||
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--add-label-ids", "customA",
|
||||
"--add-folder", "folderA",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/messages/batch_modify`,
|
||||
`validation_api_plan`,
|
||||
`/user_mailboxes/me/labels/customA`,
|
||||
`/user_mailboxes/me/folders/folderA`,
|
||||
`will_validate`,
|
||||
`batch_size`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||
}
|
||||
|
||||
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err with --yes: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||
t.Fatalf("message_ids len = %d, want 2", got)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "trash newline in repeated flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "trash tab in csv flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "modify space in repeated flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||
},
|
||||
{
|
||||
name: "modify space in csv flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
141
shortcuts/mail/mail_message_modify.go
Normal file
141
shortcuts/mail/mail_message_modify.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type messageModifyInput struct {
|
||||
MessageIDs []string
|
||||
AddLabelIDs []string
|
||||
RemoveLabelIDs []string
|
||||
AddFolder string
|
||||
CustomLabelIDs []string
|
||||
CustomFolderID string
|
||||
ValidationAPIPlans []validationAPIPlan
|
||||
}
|
||||
|
||||
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||
// state labels, or a folder move to existing messages in batches of 20.
|
||||
var MailMessageModify = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-modify",
|
||||
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
ConditionalScopes: []string{
|
||||
"mail:user_mailbox.folder:read",
|
||||
},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||
},
|
||||
Validate: validateMessageModify,
|
||||
DryRun: dryRunMessageModify,
|
||||
Execute: executeMessageModify,
|
||||
}
|
||||
|
||||
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := buildMessageModifyInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, _ := buildMessageModifyInput(rt)
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, err := buildMessageModifyInput(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||
emitMessageManageSummary(rt, messageManageSummary{
|
||||
SuccessMessageIDs: input.MessageIDs,
|
||||
FailedMessageIDs: []messageManageFailure{},
|
||||
}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message modify batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||
customFolderID := ""
|
||||
if customFolder {
|
||||
customFolderID = folder
|
||||
}
|
||||
return messageModifyInput{
|
||||
MessageIDs: messageIDs,
|
||||
AddLabelIDs: addLabels,
|
||||
RemoveLabelIDs: removeLabels,
|
||||
AddFolder: folder,
|
||||
CustomLabelIDs: customLabels,
|
||||
CustomFolderID: customFolderID,
|
||||
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||
}, nil
|
||||
}
|
||||
75
shortcuts/mail/mail_message_trash.go
Normal file
75
shortcuts/mail/mail_message_trash.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||
// runner requires --yes before Execute.
|
||||
var MailMessageTrash = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-trash",
|
||||
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||
},
|
||||
Validate: validateMessageTrash,
|
||||
DryRun: dryRunMessageTrash,
|
||||
Execute: executeMessageTrash,
|
||||
}
|
||||
|
||||
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Soft-delete messages sequentially in batches of 20").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||
Body(map[string]interface{}{"message_ids": batch})
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||
map[string]interface{}{"message_ids": batch})
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message trash batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
283
shortcuts/mail/message_manage_helpers.go
Normal file
283
shortcuts/mail/message_manage_helpers.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const mailMessageManageBatchSize = 20
|
||||
|
||||
var messageManageSystemLabels = map[string]string{
|
||||
"UNREAD": "UNREAD",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"OTHER": "OTHER",
|
||||
"FLAGGED": "FLAGGED",
|
||||
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||
}
|
||||
|
||||
var messageManageSystemFolders = map[string]string{
|
||||
"INBOX": "INBOX",
|
||||
"SENT": "SENT",
|
||||
"SPAM": "SPAM",
|
||||
"ARCHIVE": "ARCHIVED",
|
||||
"ARCHIVED": "ARCHIVED",
|
||||
}
|
||||
|
||||
type messageManageSummary struct {
|
||||
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||
}
|
||||
|
||||
type messageManageFailure struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type validationAPIPlan struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
WillValidate bool `json:"will_validate"`
|
||||
}
|
||||
|
||||
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
parts, err := splitMessageManageIDTokens(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateMessageManageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||
parts := make([]string, 0, len(raw))
|
||||
for i, token := range raw {
|
||||
for _, r := range token {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Split(token, ",")...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func validateMessageManageID(id string, index int) error {
|
||||
if len(id) < 16 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||
}
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||
}
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '/', '=', '_', '-':
|
||||
continue
|
||||
default:
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||
labels := make([]string, 0, len(raw))
|
||||
custom := make([]string, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for i, part := range raw {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||
}
|
||||
normalized := id
|
||||
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||
normalized = system
|
||||
} else {
|
||||
custom = append(custom, id)
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
labels = append(labels, normalized)
|
||||
}
|
||||
if len(labels) > 20 {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||
}
|
||||
return labels, custom, nil
|
||||
}
|
||||
|
||||
func validateLabelIntersection(add, remove []string) error {
|
||||
removeSet := make(map[string]struct{}, len(remove))
|
||||
for _, id := range remove {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
folder := strings.TrimSpace(raw)
|
||||
if folder == "" {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||
}
|
||||
if folder != raw {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||
}
|
||||
if strings.EqualFold(folder, "TRASH") {
|
||||
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||
}
|
||||
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||
return system, false, nil
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func chunkMessageManageIDs(ids []string) [][]string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||
end := start + mailMessageManageBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateLabelReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateFolderReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||
body := map[string]interface{}{"message_ids": ids}
|
||||
if len(addLabels) > 0 {
|
||||
body["add_label_ids"] = addLabels
|
||||
}
|
||||
if len(removeLabels) > 0 {
|
||||
body["remove_label_ids"] = removeLabels
|
||||
}
|
||||
if addFolder != "" {
|
||||
body["add_folder"] = addFolder
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||
seenLabels := map[string]struct{}{}
|
||||
for _, id := range customLabels {
|
||||
if _, ok := seenLabels[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenLabels[id] = struct{}{}
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "labels", id),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
if customFolder != "" {
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||
if noAPICalls {
|
||||
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||
}
|
||||
for _, item := range summary.FailedMessageIDs {
|
||||
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MailMessage,
|
||||
MailMessages,
|
||||
MailMessageModify,
|
||||
MailMessageTrash,
|
||||
MailThread,
|
||||
MailTriage,
|
||||
MailWatch,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -82,12 +82,13 @@
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
9. **已读回执** —
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -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) -- 查询忙闲
|
||||
|
||||
@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`;CLI 会在写入前读取文件并展开为内联内容。
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`;PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`。
|
||||
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
|
||||
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`、`<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>`、`<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`,CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
| `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[]` 里。
|
||||
|
||||
@@ -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` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
|
||||
@@ -79,7 +79,7 @@ metadata:
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -96,13 +96,14 @@ metadata:
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message`;`+thread` 读整个会话
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
9. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
10. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
11. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -119,6 +120,8 @@ metadata:
|
||||
- 查看发送邮件后的投递状态:发送成功后查看邮件投递状态;也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
|
||||
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
|
||||
- 撤回已发送邮件:撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
|
||||
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
|
||||
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
|
||||
- 收信规则:创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
|
||||
- 分享邮件到 IM:分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
|
||||
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
|
||||
@@ -192,7 +195,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
```
|
||||
|
||||
## 编辑转发草稿
|
||||
|
||||
48
skills/lark-mail/references/lark-mail-message-modify.md
Normal file
48
skills/lark-mail/references/lark-mail-message-modify.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# mail +message-modify
|
||||
|
||||
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
|
||||
|
||||
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
|
||||
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
|
||||
lark-cli mail +message-modify --message-ids <id> --add-folder archive
|
||||
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
|
||||
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
|
||||
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
|
||||
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
|
||||
|
||||
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
|
||||
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.
|
||||
41
skills/lark-mail/references/lark-mail-message-trash.md
Normal file
41
skills/lark-mail/references/lark-mail-message-trash.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# mail +message-trash
|
||||
|
||||
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
|
||||
|
||||
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
|
||||
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
|
||||
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.
|
||||
@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
```
|
||||
|
||||
## 相关命令
|
||||
|
||||
@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
```
|
||||
|
||||
## 编辑回复草稿
|
||||
|
||||
@@ -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 域总览
|
||||
|
||||
@@ -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) 了解认证、全局参数和安全规则。
|
||||
|
||||
对妙记中的待办做新增 / 更新 / 删除(单条或批量)。写操作。
|
||||
|
||||
@@ -135,4 +134,3 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
- [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) -- 认证和全局参数
|
||||
|
||||
@@ -15,6 +15,7 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 本地生成或校验 SVG Slides / SVGlide 产物 | 先读 `references/svg-slides/README.md`,生成 local publish-ready bundle;发布层另走 `+create-svglide` 后续计划 | `references/svg-slides/README.md`、`scripts/validate_svg_deck.mjs`、`scripts/svg_slides_bundle.mjs` |
|
||||
| 已有 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,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
@@ -29,6 +30,8 @@ metadata:
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — SVG Slides / SVGlide 与当前 XML/SXSD 工作流是不同协议。两者都使用 960x540 画布,但 SVG Slides 使用 `viewBox="0 0 960 540"` 和 `slide:*` SVG 语义,XML/SXSD 使用 SML XML。处理 SVG Slides 生成或校验时,先读 [`references/svg-slides/README.md`](references/svg-slides/README.md),不要把 SVG 规则写进 `xml-schema-quick-ref.md`。**
|
||||
|
||||
**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)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
160
skills/lark-slides/references/create-svglide-boundary-study.md
Normal file
160
skills/lark-slides/references/create-svglide-boundary-study.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# create-svglide boundary study
|
||||
|
||||
## Goal
|
||||
|
||||
Use `slides +create` as the design constraint sample for `slides +create-svglide`.
|
||||
|
||||
The central rule is:
|
||||
|
||||
```text
|
||||
slides +create is a thin publisher for already-authored slide XML.
|
||||
slides +create-svglide should be a thin publisher for already-authored SVGlide artifacts.
|
||||
```
|
||||
|
||||
This document is evidence-first. It separates what the existing shortcut actually does from the broader generation and validation work described by the `lark-slides` skill.
|
||||
|
||||
## Source Surface
|
||||
|
||||
| Area | Files | Why it matters |
|
||||
| --- | --- | --- |
|
||||
| Go shortcut implementation | `shortcuts/slides/slides_create.go`, `shortcuts/slides/helpers.go`, `shortcuts/slides/slides_media_upload.go`, `shortcuts/slides/shortcuts.go` | Shows the real runtime boundary of `slides +create`. |
|
||||
| Unit tests | `shortcuts/slides/slides_create_test.go` | Shows behavior that must not drift silently. |
|
||||
| E2E proof | `tests/cli_e2e/slides/slides_create_workflow_test.go`, `tests/cli_e2e/slides/coverage.md` | Shows what is proven outside the shortcut body. |
|
||||
| Skill and references | `skills/lark-slides/SKILL.md`, `skills/lark-slides/references/lark-slides-create.md`, `xml-schema-quick-ref.md`, `validation-checklist.md`, `troubleshooting.md` | Shows which work belongs to agent guidance or scripts instead of Go shortcut code. |
|
||||
|
||||
## `slides +create` Responsibility Matrix
|
||||
|
||||
| Responsibility | Evidence | Boundary meaning |
|
||||
| --- | --- | --- |
|
||||
| Register a write shortcut named `slides +create` for user and bot auth | `shortcuts/slides/slides_create.go:24-43`, `shortcuts/slides/shortcuts.go:8-17` | The command is a shortcut wrapper, not a general slide-generation subsystem. |
|
||||
| Build a minimal presentation XML shell | `shortcuts/slides/slides_create.go:224-241` | The shortcut creates only the deck container: title plus 960x540 presentation metadata. |
|
||||
| Create the online XML presentation | `shortcuts/slides/slides_create.go:125-148` | The first real API call is presentation creation. |
|
||||
| Accept optional `--slides` as a JSON array of `<slide>` XML strings | `shortcuts/slides/slides_create.go:40-52` | Page content is supplied by the caller as final XML strings. |
|
||||
| Enforce a maximum of 10 inline slide XML strings | `shortcuts/slides/slides_create.go:50-52` | Larger decks must use the lower-level page-create API after container creation. |
|
||||
| Detect local image placeholders in submitted XML | `shortcuts/slides/helpers.go:113-153` | The shortcut only interprets one small XML convention: `<img src=\"@path\">`. |
|
||||
| Validate placeholder files before creating the presentation | `shortcuts/slides/slides_create.go:53-67` | Avoids creating an orphan deck for missing/oversized local images. |
|
||||
| Upload placeholder images and replace them with file tokens | `shortcuts/slides/slides_create.go:163-177`, `shortcuts/slides/slides_media_upload.go:119-138`, `shortcuts/slides/helpers.go:283-309` | Image upload is helper orchestration, not content generation. |
|
||||
| Submit each supplied slide XML string to the page-create API | `shortcuts/slides/slides_create.go:179-200` | The shortcut forwards caller-authored XML to the backend. |
|
||||
| Report partial progress when page creation fails | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | It does not roll back; it tells the caller where to resume. |
|
||||
| Output machine-readable creation results | `shortcuts/slides/slides_create.go:150-219` | The output is an API orchestration receipt. |
|
||||
| Optionally attempt bot-created deck permission grant | `shortcuts/slides/slides_create.go:215-217`, `shortcuts/slides/slides_create_test.go:66-198` | Bot grant is post-create convenience, not part of content semantics. |
|
||||
|
||||
## Behavior Locks From Tests
|
||||
|
||||
| Behavior | Evidence | Boundary meaning |
|
||||
| --- | --- | --- |
|
||||
| User-mode create returns `xml_presentation_id`, `title`, and `url`, without `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | User-mode output is a creation receipt, not a validation report. |
|
||||
| Missing `--title` becomes `Untitled` in dry-run and execution | `shortcuts/slides/slides_create_test.go:200-253` | Title normalization is a small deterministic convenience that belongs in the shortcut. |
|
||||
| `--slides` creates the deck first, then adds pages, then returns `slide_ids` and `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | Page creation is orchestration after container creation. |
|
||||
| `--slides []` behaves like no slides | `shortcuts/slides/slides_create_test.go:532-570` | Empty artifact lists should be explicit no-op additions, not special generators. |
|
||||
| Invalid JSON and more than 10 inline slides fail validation with `Param == "--slides"` | `shortcuts/slides/slides_create_test.go:422-505` | Input-contract errors should be structured and routeable. |
|
||||
| Missing `xml_presentation_id` from the backend fails | `shortcuts/slides/slides_create_test.go:255-283` | Creation success requires a usable resource id. |
|
||||
| URL fallback is local and does not call Drive metas or batch query | `shortcuts/slides/slides_create_test.go:649-688` | Avoid adding extra API dependencies when a local receipt can be built. |
|
||||
| Image placeholders are uploaded once per unique path and rewritten before page creation | `shortcuts/slides/slides_create_test.go:751-854` | Asset handling is publish-boundary plumbing, not design work. |
|
||||
| Missing local placeholder files fail before any API call | `shortcuts/slides/slides_create_test.go:856-877` | Local artifact existence is a publish-blocking precondition. |
|
||||
| Dry-run exposes the API plan shape and placeholder ids | `shortcuts/slides/slides_create_test.go:572-602`, `shortcuts/slides/slides_create_test.go:879-900` | Dry-run should describe orchestration, not execute validation-heavy side effects. |
|
||||
| Readback is proven by E2E as a separate follow-up call | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`, `tests/cli_e2e/slides/coverage.md:9-16` | Readback is evidence for tests and delivery, not default `Execute` behavior. |
|
||||
| Bot permission grant is non-fatal and tri-state: granted, skipped, or failed | `shortcuts/slides/slides_create_test.go:66-198` | Convenience post-actions must not turn creation success into failure. |
|
||||
|
||||
## `slides +create` Does Not Do
|
||||
|
||||
| Non-responsibility | Evidence | Design implication for `+create-svglide` |
|
||||
| --- | --- | --- |
|
||||
| Does not generate slide XML from a prompt | `shortcuts/slides/slides_create.go:40-43`, `shortcuts/slides/slides_create.go:158-205` | `+create-svglide` must not become `--topic -> deck`. |
|
||||
| Does not deeply validate slide XML semantics | `shortcuts/slides/slides_create.go:44-69` | Only minimal publish-blocking validation belongs in the shortcut. |
|
||||
| Does not preview or repair layout | `shortcuts/slides/slides_create.go:125-221` | Preview and repair belong in skill/scripts or a runner before publish. |
|
||||
| Does not run readback inside `Execute` | `shortcuts/slides/slides_create.go:125-221`, `tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback is a test/proof step, not default shortcut behavior. |
|
||||
| Does not guarantee atomic creation | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | New publish shortcuts should provide recovery context, not hide partial success. |
|
||||
| Does not handle more than 10 inline pages | `shortcuts/slides/slides_create.go:18-21`, `shortcuts/slides/slides_create_test.go:441-465` | Bound the first version instead of building a complex batch manager. |
|
||||
| Does not own visual quality | `skills/lark-slides/SKILL.md:91-127`, `skills/lark-slides/SKILL.md:153-160` | Visual quality gates belong before the shortcut consumes artifacts. |
|
||||
|
||||
## Counterexamples
|
||||
|
||||
| Tempting requirement | Why it looks tempting | What `slides +create` teaches |
|
||||
| --- | --- | --- |
|
||||
| Add readback by default | E2E uses readback to prove persistence. | E2E calls the get API after creation; `Execute` itself stops after outputting the create result. Keep readback optional or outside MVP. |
|
||||
| Validate every page semantically before calling the backend | Better local errors sound useful. | `+create` only validates JSON shape, count, and local placeholder files; backend owns XML parsing. For SVGlide, only validate fields required to route and publish. |
|
||||
| Run preview lint and auto-repair | SVGlide has preview tooling. | `+create` does not make layout judgments. Preview lint and repair must remain pre-publish tooling. |
|
||||
| Accept a prompt and generate the deck | Higher-level UX is attractive. | `+create` consumes final submission artifacts. A prompt-to-deck runner would be a different command or script layer. |
|
||||
| Hide partial failures by retrying/rebuilding automatically | It feels friendlier. | `+create` surfaces partial progress instead. Recovery should be explicit and resumable. |
|
||||
|
||||
## `slides +create-svglide` Allowed Extra Responsibilities
|
||||
|
||||
`+create-svglide` can be slightly heavier than `+create` only where SVGlide's input contract requires it. The extra work must still be publish-boundary work, not generation work.
|
||||
|
||||
| Extra responsibility | Allowed because | Limit |
|
||||
| --- | --- | --- |
|
||||
| Read a SVGlide manifest or run directory | Unlike `--slides`, SVGlide artifacts are file-based. | Normalize to one manifest model immediately; do not infer design intent. |
|
||||
| Validate manifest schema and page order | Needed to know what to publish. | Validate shape and required fields only. |
|
||||
| Validate page file existence and path safety | Equivalent to `+create` validating `@path` placeholders. | Do not inspect aesthetics or text quality. |
|
||||
| Validate publish-required SVGlide fields | The target publish API or parser may require namespace, contract/version, dimensions, or roles before it can accept a page. | Check only required markers; do not rewrite ordinary SVG into protocol SVG in the shortcut. |
|
||||
| Upload declared local assets | Equivalent to `+create` uploading `@path` images. | Upload and token replacement only; no asset search or generation. |
|
||||
| Submit SVGlide pages to the target publish API | Equivalent to `+create` submitting each slide XML string. | Keep output and partial-progress behavior explicit; do not assume the CLI must convert to XML if the backend can consume SVGlide directly. |
|
||||
|
||||
## `slides +create-svglide` Must Not Own
|
||||
|
||||
| Responsibility | Owner |
|
||||
| --- | --- |
|
||||
| Research, outline, design brief, slide content planning | `skills/lark-slides` guidance and external runner/scripts |
|
||||
| SVG authoring | Agent or runner before publish |
|
||||
| Preview rendering, preview lint, and repair loop | Skill scripts or runner before publish |
|
||||
| Visual quality scoring | Skill/scripts/quality gate, not shortcut `Execute` |
|
||||
| Readback as default success criterion | E2E or optional verification flag |
|
||||
| PPE/Whistle routing as core naming | Environment/profile layer only |
|
||||
|
||||
## MVP Scope
|
||||
|
||||
Recommended first implementation:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
|
||||
```
|
||||
|
||||
MVP behavior:
|
||||
|
||||
1. Parse manifest.
|
||||
2. Validate required fields, page order, file existence, path safety, dimensions, and minimal SVGlide contract markers.
|
||||
3. Create presentation shell.
|
||||
4. Upload local assets declared in the manifest.
|
||||
5. Submit pages to the backend.
|
||||
6. Output `xml_presentation_id`, `url`, `page_ids` or `slide_ids`, uploaded asset count, and partial-progress context on failure.
|
||||
|
||||
MVP exclusions:
|
||||
|
||||
1. No prompt input.
|
||||
2. No generation stages.
|
||||
3. No preview repair.
|
||||
4. No default readback.
|
||||
5. No PPE-specific command name, directory name, or type name.
|
||||
|
||||
## Test Boundary For `+create-svglide`
|
||||
|
||||
The first test suite should mirror the shape of `slides +create` tests instead of proving the whole SVGlide generation pipeline.
|
||||
|
||||
| Test area | Required proof |
|
||||
| --- | --- |
|
||||
| Input contract | Invalid manifest, missing page file, unsafe path, and unsupported page count fail with structured params. |
|
||||
| Dry-run | Shows create, asset upload, and page publish steps with placeholder presentation id and deterministic step labels. |
|
||||
| Asset handling | Duplicate local assets upload once; page payloads reference uploaded tokens before publish. |
|
||||
| Partial failure | If the deck exists and page N fails, error includes presentation id, failed page index, and successfully published page count. |
|
||||
| Bot grant | Inherit user/bot output behavior from `slides +create`; grant failure is reported but not promoted to create failure. |
|
||||
| E2E | Create/publish result is asserted first; optional readback is a separate proof step unless the command explicitly adds a `--readback` contract. |
|
||||
|
||||
## Team Finding
|
||||
|
||||
The effective research team for this boundary is:
|
||||
|
||||
| Role | Scope |
|
||||
| --- | --- |
|
||||
| Code Reader | Extract runtime responsibilities from Go implementation. |
|
||||
| Test Reader | Extract behavior locks and prove what is outside `Execute`. |
|
||||
| Skill Boundary Reader | Separate agent/script responsibilities from shortcut responsibilities. |
|
||||
| Architect/Skeptic | Reject over-broad scope and map only proven `+create` patterns into `+create-svglide`. |
|
||||
|
||||
The team's proof standard is not "we read the files"; it is:
|
||||
|
||||
```text
|
||||
Every proposed +create-svglide responsibility must map to either:
|
||||
1. an existing +create responsibility, or
|
||||
2. a minimal extra responsibility forced by SVGlide's artifact input shape.
|
||||
```
|
||||
@@ -0,0 +1,160 @@
|
||||
# create-svglide 边界研究
|
||||
|
||||
## 目标
|
||||
|
||||
把 `slides +create` 作为 `slides +create-svglide` 的设计约束样本。
|
||||
|
||||
核心规则是:
|
||||
|
||||
```text
|
||||
slides +create 是已经写好的 slide XML 的薄发布器。
|
||||
slides +create-svglide 也应该是已经生成好的 SVGlide 产物的薄发布器。
|
||||
```
|
||||
|
||||
本文档以证据为先,区分现有 shortcut 真实承担的职责,以及 `lark-slides` skill 中描述的更宽泛的生成与验证工作。
|
||||
|
||||
## 研究范围
|
||||
|
||||
| 范围 | 文件 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| Go shortcut 实现 | `shortcuts/slides/slides_create.go`、`shortcuts/slides/helpers.go`、`shortcuts/slides/slides_media_upload.go`、`shortcuts/slides/shortcuts.go` | 确认 `slides +create` 的真实运行时边界。 |
|
||||
| 单元测试 | `shortcuts/slides/slides_create_test.go` | 确认可被测试锁定、不能随意漂移的行为。 |
|
||||
| E2E 证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go`、`tests/cli_e2e/slides/coverage.md` | 确认哪些证明发生在 shortcut 外部。 |
|
||||
| Skill 与 references | `skills/lark-slides/SKILL.md`、`skills/lark-slides/references/lark-slides-create.md`、`xml-schema-quick-ref.md`、`validation-checklist.md`、`troubleshooting.md` | 确认哪些工作属于 agent 指导或脚本,而不是 Go shortcut。 |
|
||||
|
||||
## `slides +create` 职责矩阵
|
||||
|
||||
| 职责 | 证据 | 边界含义 |
|
||||
| --- | --- | --- |
|
||||
| 注册一个名为 `slides +create` 的写操作 shortcut,支持 user 和 bot 身份 | `shortcuts/slides/slides_create.go:24-43`、`shortcuts/slides/shortcuts.go:8-17` | 这是 shortcut 封装,不是通用幻灯片生成系统。 |
|
||||
| 构造最小 presentation XML 外壳 | `shortcuts/slides/slides_create.go:224-241` | shortcut 只创建 deck 容器:标题和 960x540 presentation 元数据。 |
|
||||
| 创建在线 XML presentation | `shortcuts/slides/slides_create.go:125-148` | 第一个真实 API 调用是创建 presentation。 |
|
||||
| 接收可选 `--slides`,格式为 `<slide>` XML 字符串 JSON 数组 | `shortcuts/slides/slides_create.go:40-52` | 页面内容由调用方以最终 XML 字符串形式提供。 |
|
||||
| 限制一次内联提交最多 10 页 slide XML | `shortcuts/slides/slides_create.go:50-52` | 更大的 deck 应先创建容器,再用底层 page-create API 追加页面。 |
|
||||
| 检测已提交 XML 里的本地图片占位符 | `shortcuts/slides/helpers.go:113-153` | shortcut 只理解一个很窄的 XML 约定:`<img src="@path">`。 |
|
||||
| 创建 presentation 前校验占位符文件 | `shortcuts/slides/slides_create.go:53-67` | 避免因为本地图片缺失或超限而创建孤儿 deck。 |
|
||||
| 上传占位符图片并替换为 file token | `shortcuts/slides/slides_create.go:163-177`、`shortcuts/slides/slides_media_upload.go:119-138`、`shortcuts/slides/helpers.go:283-309` | 图片上传是发布边界上的 helper 编排,不是内容生成。 |
|
||||
| 把每个调用方提供的 slide XML 字符串提交给 page-create API | `shortcuts/slides/slides_create.go:179-200` | shortcut 把调用方写好的 XML 转交给后端。 |
|
||||
| 页面创建失败时报告部分进度 | `shortcuts/slides/slides_create.go:194-196`、`shortcuts/slides/slides_create_test.go:354-420` | 不回滚;告诉调用方从哪里恢复。 |
|
||||
| 输出机器可读的创建结果 | `shortcuts/slides/slides_create.go:150-219` | 输出是 API 编排回执。 |
|
||||
| bot 创建 deck 后可选尝试给当前用户授权 | `shortcuts/slides/slides_create.go:215-217`、`shortcuts/slides/slides_create_test.go:66-198` | bot grant 是创建后的便利动作,不属于内容语义。 |
|
||||
|
||||
## 测试锁定的行为
|
||||
|
||||
| 行为 | 证据 | 边界含义 |
|
||||
| --- | --- | --- |
|
||||
| user 模式创建返回 `xml_presentation_id`、`title`、`url`,不返回 `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | user 模式输出是创建回执,不是验证报告。 |
|
||||
| 省略 `--title` 时,dry-run 和真实执行都归一为 `Untitled` | `shortcuts/slides/slides_create_test.go:200-253` | 标题归一是适合放在 shortcut 内的小型确定性便利。 |
|
||||
| `--slides` 会先创建 deck,再添加页面,最后返回 `slide_ids` 和 `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | 页面创建是容器创建后的编排。 |
|
||||
| `--slides []` 等价于不传 slides | `shortcuts/slides/slides_create_test.go:532-570` | 空产物列表应是明确的无追加操作,不应触发特殊生成逻辑。 |
|
||||
| 非法 JSON 和超过 10 个内联 slides 会以 `Param == "--slides"` 的校验错误失败 | `shortcuts/slides/slides_create_test.go:422-505` | 输入契约错误必须结构化,便于调用方路由处理。 |
|
||||
| 后端缺少 `xml_presentation_id` 时失败 | `shortcuts/slides/slides_create_test.go:255-283` | 创建成功必须拿到可用资源 id。 |
|
||||
| URL fallback 在本地构造,不调用 Drive metas 或 batch query | `shortcuts/slides/slides_create_test.go:649-688` | 能用本地回执构造的内容,不应增加额外 API 依赖。 |
|
||||
| 图片占位符按唯一路径上传一次,并在页面创建前完成替换 | `shortcuts/slides/slides_create_test.go:751-854` | 素材处理是发布边界的管道能力,不是设计工作。 |
|
||||
| 本地占位符文件缺失时,在任何 API 调用前失败 | `shortcuts/slides/slides_create_test.go:856-877` | 本地产物存在性是发布前置条件。 |
|
||||
| Dry-run 暴露 API 计划形状和占位 id | `shortcuts/slides/slides_create_test.go:572-602`、`shortcuts/slides/slides_create_test.go:879-900` | Dry-run 应描述编排计划,而不是执行重型校验副作用。 |
|
||||
| Readback 在 E2E 中作为单独 follow-up 调用证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`、`tests/cli_e2e/slides/coverage.md:9-16` | Readback 是测试和交付证据,不是默认 `Execute` 行为。 |
|
||||
| Bot 授权是非致命三态:granted、skipped、failed | `shortcuts/slides/slides_create_test.go:66-198` | 便利性的后置动作不应把创建成功升级成失败。 |
|
||||
|
||||
## `slides +create` 不负责的事情
|
||||
|
||||
| 非职责 | 证据 | 对 `+create-svglide` 的设计含义 |
|
||||
| --- | --- | --- |
|
||||
| 不从 prompt 生成 slide XML | `shortcuts/slides/slides_create.go:40-43`、`shortcuts/slides/slides_create.go:158-205` | `+create-svglide` 不能变成 `--topic -> deck`。 |
|
||||
| 不深度校验 slide XML 语义 | `shortcuts/slides/slides_create.go:44-69` | shortcut 内只应放发布阻塞级的最小校验。 |
|
||||
| 不预览或修复布局 | `shortcuts/slides/slides_create.go:125-221` | preview 和 repair 属于发布前的 skill/scripts 或 runner。 |
|
||||
| 不在 `Execute` 内做 readback | `shortcuts/slides/slides_create.go:125-221`、`tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback 是测试/证明步骤,不是默认 shortcut 行为。 |
|
||||
| 不保证原子创建 | `shortcuts/slides/slides_create.go:194-196`、`shortcuts/slides/slides_create_test.go:354-420` | 新发布类 shortcut 应提供恢复上下文,而不是隐藏部分成功。 |
|
||||
| 不处理超过 10 个内联页面 | `shortcuts/slides/slides_create.go:18-21`、`shortcuts/slides/slides_create_test.go:441-465` | 第一版应设边界,而不是一开始实现复杂批处理器。 |
|
||||
| 不负责视觉质量 | `skills/lark-slides/SKILL.md:91-127`、`skills/lark-slides/SKILL.md:153-160` | 视觉质量门禁应发生在 shortcut 消费产物之前。 |
|
||||
|
||||
## 反例
|
||||
|
||||
| 诱人的需求 | 为什么看起来合理 | `slides +create` 给出的约束 |
|
||||
| --- | --- | --- |
|
||||
| 默认加入 readback | E2E 用 readback 证明持久化。 | E2E 是创建后另调 get API;`Execute` 输出创建结果后即结束。Readback 应保持可选或放在 MVP 外。 |
|
||||
| 调后端前语义校验每一页 | 本地错误更友好。 | `+create` 只校验 JSON 形状、页数、本地占位符文件;XML 解析由后端负责。SVGlide 也只校验发布路由必需字段。 |
|
||||
| 运行 preview lint 并自动 repair | SVGlide 有 preview 工具链。 | `+create` 不做布局判断。Preview lint 和 repair 应留在发布前工具链。 |
|
||||
| 接受 prompt 并生成 deck | 高层 UX 很吸引人。 | `+create` 消费最终提交物。Prompt-to-deck runner 应是另一层命令或脚本。 |
|
||||
| 通过自动重试/重建隐藏部分失败 | 看起来更友好。 | `+create` 暴露部分进度。恢复应该显式、可续跑。 |
|
||||
|
||||
## `slides +create-svglide` 允许新增的职责
|
||||
|
||||
`+create-svglide` 只能在 SVGlide 输入契约强制要求的地方比 `+create` 稍重。新增工作仍必须属于发布边界,而不是生成边界。
|
||||
|
||||
| 额外职责 | 允许原因 | 限制 |
|
||||
| --- | --- | --- |
|
||||
| 读取 SVGlide manifest 或 run directory | 与 `--slides` 不同,SVGlide 产物是文件型产物。 | 立即归一化为一个 manifest 模型;不要推断设计意图。 |
|
||||
| 校验 manifest schema 和页序 | 需要知道要发布什么。 | 只校验形状和必填字段。 |
|
||||
| 校验页面文件存在性和路径安全 | 等价于 `+create` 校验 `@path` 占位符。 | 不检查美观度或文本质量。 |
|
||||
| 校验发布必需的 SVGlide 字段 | 目标发布 API 或 parser 可能需要 namespace、contract/version、尺寸或 role 才能接收页面。 | 只检查必需标记;不要在 shortcut 中把普通 SVG 重写成协议 SVG。 |
|
||||
| 上传声明的本地素材 | 等价于 `+create` 上传 `@path` 图片。 | 只做上传和 token 替换;不做素材搜索或生成。 |
|
||||
| 把 SVGlide 页面提交给目标发布 API | 等价于 `+create` 提交每个 slide XML 字符串。 | 保持输出和部分进度语义明确;如果后端能直接消费 SVGlide,不要假设 CLI 必须转 XML。 |
|
||||
|
||||
## `slides +create-svglide` 必须不拥有的职责
|
||||
|
||||
| 职责 | 所属边界 |
|
||||
| --- | --- |
|
||||
| research、outline、design brief、slide content planning | `skills/lark-slides` 指导和外部 runner/scripts |
|
||||
| SVG authoring | agent 或 runner,在发布前完成 |
|
||||
| preview rendering、preview lint、repair loop | skill scripts 或 runner,在发布前完成 |
|
||||
| 视觉质量评分 | skill/scripts/quality gate,不属于 shortcut `Execute` |
|
||||
| readback 作为默认成功标准 | E2E 或可选验证 flag |
|
||||
| PPE/Whistle 路由进入核心命名 | 只能属于环境/profile 层 |
|
||||
|
||||
## MVP 范围
|
||||
|
||||
推荐第一版实现:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
|
||||
```
|
||||
|
||||
MVP 行为:
|
||||
|
||||
1. 解析 manifest。
|
||||
2. 校验必填字段、页序、文件存在性、路径安全、尺寸、最小 SVGlide contract 标记。
|
||||
3. 创建 presentation 外壳。
|
||||
4. 上传 manifest 声明的本地素材。
|
||||
5. 把页面提交给后端。
|
||||
6. 输出 `xml_presentation_id`、`url`、`page_ids` 或 `slide_ids`、上传素材数量,以及失败时的部分进度上下文。
|
||||
|
||||
MVP 排除项:
|
||||
|
||||
1. 不接受 prompt 输入。
|
||||
2. 不包含生成阶段。
|
||||
3. 不做 preview repair。
|
||||
4. 不默认 readback。
|
||||
5. 不在命令名、目录名或类型名中包含 PPE。
|
||||
|
||||
## `+create-svglide` 测试边界
|
||||
|
||||
第一版测试应镜像 `slides +create` 的测试形状,而不是证明完整 SVGlide 生成流水线。
|
||||
|
||||
| 测试范围 | 必须证明 |
|
||||
| --- | --- |
|
||||
| 输入契约 | 非法 manifest、缺失页面文件、不安全路径、不支持的页数以结构化 param 失败。 |
|
||||
| Dry-run | 展示 create、asset upload、page publish 步骤,包含占位 presentation id 和确定性的 step label。 |
|
||||
| 素材处理 | 重复本地素材只上传一次;页面 payload 在发布前引用已上传 token。 |
|
||||
| 部分失败 | deck 已存在但第 N 页失败时,错误包含 presentation id、失败页序号、已成功发布页数。 |
|
||||
| Bot grant | 继承 `slides +create` 的 user/bot 输出行为;grant 失败不升级成 create 失败。 |
|
||||
| E2E | 先断言 create/publish 结果;可选 readback 作为单独证明步骤,除非命令显式加入 `--readback` 契约。 |
|
||||
|
||||
## Team 结论
|
||||
|
||||
适合研究这个边界的 team 是:
|
||||
|
||||
| 角色 | 范围 |
|
||||
| --- | --- |
|
||||
| Code Reader | 从 Go 实现中抽取运行时职责。 |
|
||||
| Test Reader | 抽取行为锁定点,并证明哪些行为不在 `Execute` 内。 |
|
||||
| Skill Boundary Reader | 区分 agent/script 职责和 shortcut 职责。 |
|
||||
| Architect/Skeptic | 拒绝过宽 scope,只把已被 `+create` 证明的模式映射到 `+create-svglide`。 |
|
||||
|
||||
这个 team 的证明标准不是“读过文件”,而是:
|
||||
|
||||
```text
|
||||
每一个 proposed +create-svglide 职责都必须映射到:
|
||||
1. 一个已有 +create 职责;或
|
||||
2. 一个由 SVGlide 产物输入形态强制产生的最小额外职责。
|
||||
```
|
||||
41
skills/lark-slides/references/svg-slides/README.md
Normal file
41
skills/lark-slides/references/svg-slides/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# SVG Slides Local Generation
|
||||
|
||||
This reference family is for local SVG Slides generation and validation.
|
||||
|
||||
It is not the Lark Slides XML/SXSD workflow and it is not the publish shortcut. Use it to produce a local publish-ready bundle that a future `slides +create-svglide` publisher can consume.
|
||||
|
||||
## Read Routes
|
||||
|
||||
| Task | Read first | Then read |
|
||||
|---|---|---|
|
||||
| Generate a new SVG deck | `workflow.md` | `design-brief.md`, `protocol.md`, `authoring-rules.md`, `visual-design.md`, `validation.md` |
|
||||
| Repair protocol failures | `validation.md` | `protocol.md`, `authoring-rules.md` |
|
||||
| Improve visual quality | `visual-design.md` | `design-brief.md`, `workflow.md` |
|
||||
| Use charts | `chart-workflow.md` | `protocol.md`, `validation.md` |
|
||||
| Continue an existing deck | `editing-existing-decks.md` | `workflow.md`, `protocol.md` |
|
||||
| Audit provenance | `source/split-manifest.json` | `source/full.debranded.md` |
|
||||
|
||||
## Boundary
|
||||
|
||||
Generation and validation produce a local publish-ready bundle.
|
||||
|
||||
A future SVG Slides publisher consumes this bundle. That publishing path is intentionally outside this reference family.
|
||||
|
||||
A local bundle may set `publish_ready=true`; it must not claim it is published.
|
||||
|
||||
## Canvas Decision
|
||||
|
||||
This CLI adaptation uses a 960x540 SVG canvas: `viewBox="0 0 960 540"`.
|
||||
|
||||
The source snapshot is preserved for provenance and coverage audit. Where the source describes a different default canvas, the CLI adaptation layer intentionally normalizes generated SVG Slides to 960x540.
|
||||
|
||||
## Required Local Gates
|
||||
|
||||
1. `node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir> --json`
|
||||
2. `node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"`
|
||||
3. Browser text-boundary check when Playwright is available.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: title
|
||||
- Coverage mode: routing entry; source text is preserved in `source/full.debranded.md`, while this file points workers to the coverage-preserving split docs.
|
||||
79
skills/lark-slides/references/svg-slides/authoring-rules.md
Normal file
79
skills/lark-slides/references/svg-slides/authoring-rules.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# SVG Slides Authoring Rules
|
||||
|
||||
## Required Authoring Pattern
|
||||
|
||||
Write complete slide files. A slide edit is not a fragment, patch, or HTML page.
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Optional `<defs>`.
|
||||
2. One background as the first rendered child.
|
||||
3. Top-level shapes, images, charts, groups, and optional notes.
|
||||
|
||||
Every rendered element that the slide engine must understand needs the appropriate `slide:role`. Do not depend on generic browser rendering when the protocol has an explicit semantic role.
|
||||
|
||||
## Forbidden Constructs
|
||||
|
||||
Do not use:
|
||||
|
||||
- `<style>` blocks;
|
||||
- `class=`;
|
||||
- `<div>` or `<section>` wrappers in text `foreignObject`;
|
||||
- bare text under `foreignObject`;
|
||||
- SVG `<text>`;
|
||||
- SVG `<marker>`;
|
||||
- hex colors;
|
||||
- named colors;
|
||||
- `none` for `fill` or `stroke`;
|
||||
- role-less primitives in the rendered slide body.
|
||||
|
||||
## Text Boxes
|
||||
|
||||
Use plain text boxes for text-only content:
|
||||
|
||||
```xml
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="96" y="96" width="640" height="120" style="font-size:32px;color:rgba(15,23,42,1);line-height:1.2">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Main argument</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Use shape-with-text only when the object is truly one styled box with one text block. If the card has multiple parts, use `<g slide:role="group">`.
|
||||
|
||||
## Image Elements
|
||||
|
||||
Use images when there is a real image asset or generated visual. The SVG file references the local asset path.
|
||||
|
||||
Informational images such as charts, diagrams, screenshots, and infographics must preserve their original ratio. Decorative images may be composed more freely, but should still fit the resolved design brief.
|
||||
|
||||
Unless the user explicitly requests no images, cover, section divider, and closing pages should use a large hero image or generated visual. Full-bleed image backgrounds use `<image slide:role="background">`; large non-background images use `<image slide:role="image" slide:shape-type="image">`.
|
||||
|
||||
When text sits on an image, place a semi-transparent `<rect slide:role="shape" slide:shape-type="shape">` scrim or a solid text zone after the image and before the text. Do not use SVG `<mask>` for this readability layer.
|
||||
|
||||
Generated cover, section divider, or closing images must not contain baked-in text. Render text as slide text on top of the image.
|
||||
|
||||
## Chart Embeds
|
||||
|
||||
A chart is an external SVG sidecar referenced by:
|
||||
|
||||
```xml
|
||||
<rect slide:role="chart" href="resources/charts/example.svg" x="120" y="180" width="800" height="500"/>
|
||||
```
|
||||
|
||||
Do not hand-draw a chart from primitives when the slide's point depends on a real quantitative data series. Use the chart workflow to generate the sidecar first.
|
||||
|
||||
## Custom Paths
|
||||
|
||||
Custom paths require accurate bounds. `slide:width` and `slide:height` describe the real extent of the path data, not the full canvas.
|
||||
|
||||
If a path has not been normalized, measure its bounding box before writing the final slide. Oversized path boxes make selection, hit testing, and layout misleading.
|
||||
|
||||
## Grouped Cards
|
||||
|
||||
Use `<g slide:role="group">` for a multi-element cluster: card background, badge, icon, title, body, chart, image, or connector. Each child still carries its own role.
|
||||
|
||||
Do not use `<g slide:role="shape">` as a generic container. It is only for the shape-with-text form.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: slides_edit_tool, image_usage, compute_custom_shape_bbox_tool
|
||||
- Coverage mode: preserve authoring constraints and tool semantics that affect generated SVG structure.
|
||||
64
skills/lark-slides/references/svg-slides/chart-workflow.md
Normal file
64
skills/lark-slides/references/svg-slides/chart-workflow.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# SVG Slides Chart Workflow
|
||||
|
||||
## When To Use A Chart
|
||||
|
||||
Use a chart when the slide's point depends on a real quantitative series:
|
||||
|
||||
- trend;
|
||||
- multi-category comparison;
|
||||
- part-to-whole split;
|
||||
- distribution;
|
||||
- ranking;
|
||||
- two-dimensional positioning.
|
||||
|
||||
For single numbers or trivial two-bucket comparisons, prefer a large text callout unless the comparison needs a chart to be understood.
|
||||
|
||||
## When Not To Use A Chart
|
||||
|
||||
Do not generate a chart for vague, unsourced, decorative, or invented data. Do not choose a chart type because the raw data happens to look compatible; choose it because the takeaway requires that representation.
|
||||
|
||||
When in doubt, a sorted bar chart is safer than a pie or doughnut.
|
||||
|
||||
## Chart Sidecar Contract
|
||||
|
||||
A chart is generated as an SVG sidecar before slide authoring and embedded by reference.
|
||||
|
||||
The generation request must decide the takeaway first. The takeaway must be faithful to the data and short enough to guide chart design.
|
||||
|
||||
The request must include:
|
||||
|
||||
- chart type;
|
||||
- JSON data matching that type;
|
||||
- style matching the destination slide;
|
||||
- actual on-slide width and height;
|
||||
- output path under `resources/charts/`.
|
||||
|
||||
The declared chart width should match the embed width. Chart internals derive text size from width. Do not declare a wide chart and embed it in a narrow slot.
|
||||
|
||||
## Embed Contract
|
||||
|
||||
Embed a generated chart with:
|
||||
|
||||
```xml
|
||||
<rect slide:role="chart" href="resources/charts/name.svg" x="120" y="180" width="800" height="500"/>
|
||||
```
|
||||
|
||||
The embed width and height must match the chart sidecar's intended display size. Keep a 16:10-ish chart area when possible to avoid letterboxing.
|
||||
|
||||
One chart should carry one distinct insight. Pair charts with short callouts or labels, and vary chart composition across the deck.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
Static deck validation confirms the chart placeholder shape, not the correctness of the chart sidecar data. Review chart sidecars for:
|
||||
|
||||
- source-backed data;
|
||||
- truthful takeaway;
|
||||
- readable labels;
|
||||
- width at or above the practical floor;
|
||||
- matching palette;
|
||||
- intact `href`.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: generate_svg_chart_tool
|
||||
- Coverage mode: preserve chart generation, data contract, rendering constraints, and validation expectations.
|
||||
80
skills/lark-slides/references/svg-slides/design-brief.md
Normal file
80
skills/lark-slides/references/svg-slides/design-brief.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# SVG Slides Design Brief
|
||||
|
||||
## Inputs
|
||||
|
||||
Resolve the design brief after these inputs are known:
|
||||
|
||||
- topic and goal;
|
||||
- audience;
|
||||
- delivery mode;
|
||||
- language;
|
||||
- page count when known;
|
||||
- source material;
|
||||
- user-fixed brand, color, or content constraints;
|
||||
- one to three short visual-direction phrases.
|
||||
|
||||
Do not ask the user to choose tone, density, palette, or typography unless they volunteered hard constraints.
|
||||
|
||||
## Output Contract
|
||||
|
||||
The brief must produce:
|
||||
|
||||
- `narrative_spine`;
|
||||
- `depth`;
|
||||
- `tone`;
|
||||
- `visual_system`.
|
||||
|
||||
These outputs govern outline, content density, wording, asset choices, typography, color, layout, and decoration.
|
||||
|
||||
## narrative_spine
|
||||
|
||||
`narrative_spine` defines the slide sequence discipline. It is the default source of order, sectioning, and narrative movement.
|
||||
|
||||
The user can override it by giving or editing an outline. After that point, the user outline wins.
|
||||
|
||||
## depth
|
||||
|
||||
`depth` decides altitude and density:
|
||||
|
||||
- how much context each slide carries;
|
||||
- what to include and exclude;
|
||||
- how many main points per slide;
|
||||
- how source evidence should appear;
|
||||
- whether a page should split instead of cram.
|
||||
|
||||
## tone
|
||||
|
||||
`tone` controls writing style and evidence posture. It should reflect the audience and delivery mode.
|
||||
|
||||
Presented decks can use shorter on-slide wording because the speaker carries context. Self-read decks need more complete explanatory text but still must avoid walls of text.
|
||||
|
||||
## visual_system
|
||||
|
||||
`visual_system` is the authority for look and feel. It should include:
|
||||
|
||||
- color logic;
|
||||
- typography category and treatment;
|
||||
- layout grammar;
|
||||
- imagery or material direction;
|
||||
- page-role imagery defaults for cover, section divider, and closing pages;
|
||||
- decoration and motif rules;
|
||||
- constraints to avoid.
|
||||
|
||||
Unless the user explicitly requests no images, `visual_system` must specify how cover, section divider, and closing pages use a high-impact hero image or generated visual. The brief should describe the imagery subject, treatment, crop attitude, and how foreground text stays readable.
|
||||
|
||||
Font mapping must preserve the same category and treatment. Do not swap serif and sans, ignore uppercase treatment, or pick generic fonts when the brief calls for a distinctive style.
|
||||
|
||||
## How It Drives Generation
|
||||
|
||||
Use the brief in this order:
|
||||
|
||||
1. Shape the outline from `narrative_spine`.
|
||||
2. Size the content from `depth`.
|
||||
3. Write titles and evidence from `tone`.
|
||||
4. Build the deck-level style from `visual_system`.
|
||||
5. Author each slide's layout from the content logic plus the visual system.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: resolve_design_brief
|
||||
- Coverage mode: preserve design brief inputs, output contract, and downstream influence on outline and page authoring.
|
||||
@@ -0,0 +1,39 @@
|
||||
# SVG Slides Editing Existing Decks
|
||||
|
||||
## Continue Existing Deck
|
||||
|
||||
When the user asks to continue, edit, extend, or repair an existing uploaded deck, operate on the existing converted project instead of recreating from scratch.
|
||||
|
||||
Preserve every existing page unless the user asks to change it. A page with minimal content should remain minimal if that is what the source deck contained.
|
||||
|
||||
## Preserve Existing Pages
|
||||
|
||||
For text or layout changes, edit only the target slide files. Preserve styling by default. Restyle only when the user explicitly asks.
|
||||
|
||||
Preserve media, chart, video, and audio blocks verbatim when the request does not touch them.
|
||||
|
||||
## Add Or Delete Pages
|
||||
|
||||
Add pages through the organize workflow, then author the new standalone SVG pages.
|
||||
|
||||
Delete only pages the user asked to remove. Do not rerun the new-deck outline workflow over an existing deck; it can overwrite existing slide files and lose original pages.
|
||||
|
||||
## Template Reference Boundary
|
||||
|
||||
An uploaded reference can mean two different things:
|
||||
|
||||
- Continue or edit this deck: preserve and modify that deck.
|
||||
- Create a new deck inspired by this reference: author fresh SVG using the normal create workflow.
|
||||
|
||||
Clarify when the user's wording does not identify which behavior they want.
|
||||
|
||||
## PPTX Conversion Boundary
|
||||
|
||||
Converted decks may contain imported chart placeholders or media. Preserve legacy chart references unless the user asks to update chart data, type, theme, or emphasis.
|
||||
|
||||
If a chart is resized materially, regenerate the chart sidecar with the new dimensions rather than only squeezing the existing placeholder.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: slide_organize_tool, slides_convert_tool, slides_parse_template_tool
|
||||
- Coverage mode: preserve existing-deck continuation, conversion, and template parsing boundaries without turning them into publish behavior.
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_embed" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="56" width="680" height="72" style="font-size:36px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(17,24,39,1);font-weight:800;line-height:1.15;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:36px;line-height:1.15;color:rgba(17,24,39,1);letter-spacing:0px">Chart is a referenced sidecar</h2>
|
||||
</foreignObject>
|
||||
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="160" width="560" height="350"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="690" y="190" width="190" height="118" style="font-size:19px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(55,65,81,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:19px;line-height:1.38;color:rgba(55,65,81,1);letter-spacing:0px">The chart payload lives outside the slide and is referenced by href.</p>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="group_card" viewBox="0 0 960 540">
|
||||
<defs>
|
||||
<linearGradient id="card_grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,1)"/>
|
||||
<stop offset="100%" stop-color="rgba(226,232,240,1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(241,245,249,1)"/>
|
||||
<g slide:role="group" id="card_primary">
|
||||
<rect slide:role="shape" slide:shape-type="round-rect" x="120" y="140" width="520" height="300" rx="24" ry="24" fill="url(#card_grad)" stroke="rgba(148,163,184,1)" stroke-width="1"/>
|
||||
<circle slide:role="shape" slide:shape-type="circle" cx="180" cy="206" r="26" fill="rgba(37,99,235,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="224" y="178" width="340" height="50" style="font-size:28px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.2;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:28px;line-height:1.2;color:rgba(15,23,42,1);letter-spacing:0px">Grouped card</h2>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="154" y="264" width="420" height="86" style="font-size:20px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(51,65,85,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:20px;line-height:1.38;color:rgba(51,65,85,1);letter-spacing:0px">A card is a group; every visual child still carries its own slide role.</p>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="minimal_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(248,250,252,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="760" height="92" style="font-size:42px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.12;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
|
||||
<h1 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:42px;line-height:1.12;color:rgba(15,23,42,1);letter-spacing:0px">One protocol-compliant SVG slide</h1>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 753 B |
94
skills/lark-slides/references/svg-slides/protocol.md
Normal file
94
skills/lark-slides/references/svg-slides/protocol.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# SVG Slides Protocol
|
||||
|
||||
## Canvas
|
||||
|
||||
- Each page is one standalone SVG file.
|
||||
- Root must contain `xmlns="http://www.w3.org/2000/svg"`.
|
||||
- Root must contain `xmlns:slide="https://slides.bytedance.com/ns"`.
|
||||
- Root must contain `slide:role="slide"`.
|
||||
- Root must contain an `id`.
|
||||
- Root must contain `viewBox="0 0 960 540"`.
|
||||
- Child coordinates are in viewBox units.
|
||||
- Do not rely on HTML document behavior. SVG nodes use SVG semantics; XHTML appears only inside approved `foreignObject` children.
|
||||
- This 960x540 canvas is the CLI adaptation target. The preserved source snapshot may mention other defaults, but generated local bundles must use 960x540.
|
||||
|
||||
## Background
|
||||
|
||||
- Exactly one rendered background is required.
|
||||
- Optional `<defs>` may appear first.
|
||||
- The first rendered child after optional `<defs>` must be a `<rect>` or `<image>` with `slide:role="background"`.
|
||||
- Background must cover the full canvas.
|
||||
- Gradient backgrounds must reference gradients declared in the same slide's `<defs>`.
|
||||
- A full-bleed image background should be an `<image slide:role="background">`.
|
||||
- Text scrims over image backgrounds are normal shape overlays after the background, not additional backgrounds.
|
||||
|
||||
## Text
|
||||
|
||||
- Plain text uses `foreignObject slide:role="shape" slide:shape-type="text"`.
|
||||
- Text `foreignObject` needs numeric `x`, `y`, `width`, and `height`.
|
||||
- The first direct XHTML child must be `p`, `ul`, `ol`, `h1`, `h2`, `h3`, or `small`.
|
||||
- Do not wrap text in `div` or `section`.
|
||||
- Do not put bare text directly under `foreignObject`.
|
||||
- Text style belongs in `style`.
|
||||
- `font-size` must include `px`.
|
||||
- Text color must be `rgb(...)` or `rgba(...)`.
|
||||
- Text boxes must be sized to fit; static validation does not prove rendered wrapping.
|
||||
|
||||
## Shapes And Groups
|
||||
|
||||
- Geometry needs `slide:role="shape"` and a meaningful `slide:shape-type`.
|
||||
- Common geometry includes `rect`, `ellipse`, `circle`, `path`, and `line`.
|
||||
- Multi-element cards use `<g slide:role="group">`.
|
||||
- Children inside a group still keep their own `slide:role`.
|
||||
- A shape-with-text group is only for one geometry plus one text block. Cards with badges, icons, charts, or multiple text blocks must be regular groups.
|
||||
- Custom paths must declare a meaningful `slide:width` and `slide:height` that match the path's real bounding box.
|
||||
|
||||
## Lines
|
||||
|
||||
- Lines use `<line slide:role="shape" slide:shape-type="line">`.
|
||||
- Arrows use `slide:start-arrow` or `slide:end-arrow`.
|
||||
- SVG marker arrows are forbidden.
|
||||
|
||||
## Images
|
||||
|
||||
- Images use `<image slide:role="image" slide:shape-type="image" href="...">`.
|
||||
- Informational images preserve source aspect ratio.
|
||||
- Do not wrap a single image in a group unless it is truly part of a larger multi-element composition.
|
||||
- Borders and shadows belong on the image element itself when used.
|
||||
|
||||
## Charts
|
||||
|
||||
- Charts use `<rect slide:role="chart" href="..." x="..." y="..." width="..." height="...">`.
|
||||
- The rect is a chart placeholder; it is not a drawn rectangle.
|
||||
- Place charts at top level or inside `<g slide:role="group">`.
|
||||
- Preserve chart `href` verbatim unless the user asks to change chart data, type, emphasis, theme, or source.
|
||||
|
||||
## Notes
|
||||
|
||||
- Speaker notes are optional and do not render on canvas.
|
||||
- At most one `<slide:note>` may appear.
|
||||
- Notes contain direct paragraph children.
|
||||
|
||||
## Colors
|
||||
|
||||
- Use `rgb(...)`, `rgba(...)`, or `url(#id)`.
|
||||
- Do not use hex colors.
|
||||
- Do not use named colors.
|
||||
- Do not use `none` for `fill` or `stroke`; use `rgba(0,0,0,0)` for transparent fills.
|
||||
|
||||
## Animation
|
||||
|
||||
- Animation is part of delivery, not decoration.
|
||||
- Most slides should be static.
|
||||
- Presented decks may use progressive reveal for complex steps, charts, processes, timelines, or comparisons.
|
||||
- Self-read, formal, board, or consulting decks should read fully without clicks.
|
||||
- Use at most three builds on a slide.
|
||||
- Use one effect type per slide.
|
||||
- Animated elements need explicit `id`.
|
||||
- Animate top-level elements or top-level groups.
|
||||
- Use one deck-level page transition when needed; do not vary transition style slide by slide.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: svg_reference, svg_document_rules
|
||||
- Coverage mode: preserve hard SVG protocol requirements from the source while applying the CLI canvas adaptation to 960x540; visual guidance belongs in `visual-design.md`, not here.
|
||||
2420
skills/lark-slides/references/svg-slides/source/full.debranded.md
Normal file
2420
skills/lark-slides/references/svg-slides/source/full.debranded.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": "svg-slides.split-manifest.v1",
|
||||
"source": "skills/lark-slides/references/svg-slides/source/full.debranded.md",
|
||||
"source_export": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.debranded.md",
|
||||
"source_role": "provenance_and_coverage_authority_not_default_runtime_context",
|
||||
"sections": [
|
||||
{"id": "title", "lines": [1, 1], "target": "README.md"},
|
||||
{"id": "system_prompt_workflow", "lines": [3, 196], "target": "workflow.md"},
|
||||
{"id": "svg_reference", "lines": [198, 865], "target": "protocol.md"},
|
||||
{"id": "resolve_design_brief", "lines": [867, 1080], "target": "design-brief.md"},
|
||||
{"id": "deck_design_reference_catalog", "lines": [1082, 1234], "target": "visual-design.md"},
|
||||
{"id": "slide_outline_tool", "lines": [1236, 1254], "target": "workflow.md"},
|
||||
{"id": "activate_slides_edit_tool", "lines": [1256, 1262], "target": "workflow.md"},
|
||||
{"id": "slides_edit_tool", "lines": [1264, 1281], "target": "authoring-rules.md"},
|
||||
{"id": "svg_document_rules", "lines": [1283, 1287], "target": "protocol.md"},
|
||||
{"id": "image_usage", "lines": [1289, 1291], "target": "authoring-rules.md"},
|
||||
{"id": "incremental_processing", "lines": [1293, 1331], "target": "workflow.md"},
|
||||
{"id": "finish_slides_edit_tool", "lines": [1333, 1339], "target": "validation.md"},
|
||||
{"id": "slide_organize_tool", "lines": [1341, 1347], "target": "editing-existing-decks.md"},
|
||||
{"id": "compute_custom_shape_bbox_tool", "lines": [1349, 1355], "target": "authoring-rules.md"},
|
||||
{"id": "generate_svg_chart_tool", "lines": [1357, 2356], "target": "chart-workflow.md"},
|
||||
{"id": "slides_convert_tool", "lines": [2358, 2395], "target": "editing-existing-decks.md"},
|
||||
{"id": "slides_parse_template_tool", "lines": [2397, 2420], "target": "editing-existing-decks.md"}
|
||||
]
|
||||
}
|
||||
120
skills/lark-slides/references/svg-slides/validation.md
Normal file
120
skills/lark-slides/references/svg-slides/validation.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# SVG Slides Validation
|
||||
|
||||
## Static Protocol Validator
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> --json
|
||||
```
|
||||
|
||||
The validator checks hard protocol rules:
|
||||
|
||||
- standalone SVG root;
|
||||
- `slide:role="slide"`;
|
||||
- required namespaces;
|
||||
- `viewBox="0 0 960 540"`;
|
||||
- background order;
|
||||
- forbidden style blocks and CSS classes;
|
||||
- forbidden text wrappers;
|
||||
- color syntax;
|
||||
- text `font-size` units;
|
||||
- line role and arrow semantics;
|
||||
- XML parseability.
|
||||
|
||||
The validator is a hard gate for publish-ready local bundles.
|
||||
|
||||
## Browser Text Boundary Check
|
||||
|
||||
Static XML cannot prove final rendered wrapping, CJK font fallback, or actual text height. Run browser text-boundary QA when Playwright is available:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> --out /tmp/svg-slides-text-bounds.json
|
||||
```
|
||||
|
||||
If Playwright is unavailable, the script exits 2 and explains the missing optional dependency.
|
||||
|
||||
## Bundle Manifest
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"
|
||||
```
|
||||
|
||||
The bundle manifest records:
|
||||
|
||||
- protocol version;
|
||||
- title;
|
||||
- slide list;
|
||||
- validation receipt paths;
|
||||
- `publish_ready=true`;
|
||||
- `published=false`.
|
||||
|
||||
## Receipt Requirements
|
||||
|
||||
A local publish-ready bundle needs:
|
||||
|
||||
- `manifest.json`;
|
||||
- `receipts/validate_svg_deck.json`;
|
||||
- optional browser text-boundary receipt when browser QA ran;
|
||||
- slide files listed in deterministic order.
|
||||
|
||||
## What Passing Validation Does Not Prove
|
||||
|
||||
Passing validation does not prove visual excellence, source quality, chart truth, or backend acceptance. It proves that the generated local SVG files obey the hard protocol rules represented by the validator.
|
||||
|
||||
Always separate:
|
||||
|
||||
- protocol pass;
|
||||
- browser text-boundary pass;
|
||||
- visual design review;
|
||||
- live publish proof.
|
||||
|
||||
## Local Publish-Ready Bundle
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<deck title>"
|
||||
```
|
||||
|
||||
The command writes:
|
||||
|
||||
- `manifest.json`
|
||||
- `receipts/validate_svg_deck.json`
|
||||
|
||||
The manifest uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "svglide.manifest.v1",
|
||||
"protocol": "svg-slides.v1",
|
||||
"size": {"width": 960, "height": 540},
|
||||
"publish_ready": true,
|
||||
"published": false
|
||||
}
|
||||
```
|
||||
|
||||
`publish_ready=true` means local static validation passed. It does not mean the deck was published to Lark Slides.
|
||||
|
||||
## Browser Text Boundary QA
|
||||
|
||||
When Playwright is available in the development environment, run:
|
||||
|
||||
```bash
|
||||
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir> --out receipts/preview_text_bounds.json
|
||||
```
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0`: no text-boundary problems.
|
||||
- `1`: rendered text overflow was detected.
|
||||
- `2`: the script could not run, for example Playwright is unavailable.
|
||||
|
||||
This browser check is a generation-quality gate. It is not a publish API proof.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: finish_slides_edit_tool
|
||||
- Coverage mode: preserve finish/validation gates and explicitly separate protocol pass from visual quality pass.
|
||||
74
skills/lark-slides/references/svg-slides/visual-design.md
Normal file
74
skills/lark-slides/references/svg-slides/visual-design.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# SVG Slides Visual Design
|
||||
|
||||
## Typography
|
||||
|
||||
Use real font families that are likely to render. Keep a stable display/body pairing across the deck.
|
||||
|
||||
Titles, hero numbers, and key labels may use display fonts. Body text should use readable fonts. English and CJK decks need compatible font choices rather than generic fallback everywhere.
|
||||
|
||||
Do not switch typography per slide without a structural reason.
|
||||
|
||||
## Layout Freedom
|
||||
|
||||
SVG Slides gives full coordinate-level layout control. Use that control to encode the page's logic.
|
||||
|
||||
Start each slide by identifying the relationship in the content:
|
||||
|
||||
- comparison;
|
||||
- sequence;
|
||||
- timeline;
|
||||
- cycle;
|
||||
- hierarchy;
|
||||
- matrix or quadrant;
|
||||
- funnel;
|
||||
- part-to-whole;
|
||||
- cause to effect;
|
||||
- evidence to implication.
|
||||
|
||||
Then compose a bespoke structure using position, scale, alignment, grouping, flow direction, connectors, depth, and contrast. A layout invented for the slide's actual logic is better than a canned diagram.
|
||||
|
||||
## Visual Differentiation
|
||||
|
||||
Every substantive slide should have a visual idea: image, chart, diagram, process, comparison, spatial map, large number, table-like structure, or custom shape system.
|
||||
|
||||
Avoid repeating title-plus-bullets. Reuse deck-level motif and style, not the exact same page layout.
|
||||
|
||||
Cover, section divider, and closing pages are not exceptions. Unless the user explicitly requests no images, make these pages image-led with a high-impact hero image or generated visual. Text over imagery must use an intentional readability treatment, such as a translucent scrim or solid text zone, instead of relying on contrast by accident.
|
||||
|
||||
## Density
|
||||
|
||||
Density comes from audience and delivery mode. Split rather than cram when a slide needs more than one central idea.
|
||||
|
||||
Each slide should defend one central idea. Content slide titles should be declarative arguments, not topic labels. Cover, section, and closing slides can use shorter labels.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Avoid:
|
||||
|
||||
- generic white slides with bullets only;
|
||||
- the same card grid on every page;
|
||||
- low-contrast text;
|
||||
- decorative lines crossing text;
|
||||
- filler agenda or Q&A pages in short decks;
|
||||
- placeholder images;
|
||||
- unverified data visualization;
|
||||
- text walls;
|
||||
- overusing animation.
|
||||
|
||||
## Remaining Human Judgment
|
||||
|
||||
Static validation proves protocol shape, not taste. A deck can pass validation and still be visually weak.
|
||||
|
||||
Review visual quality separately:
|
||||
|
||||
- Does the layout express the slide's logic?
|
||||
- Does each slide have a clear central claim?
|
||||
- Are data and claims source-backed?
|
||||
- Is typography intentional and consistent?
|
||||
- Is the page readable in a browser at expected size?
|
||||
- Does the deck vary composition while staying in one visual system?
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: deck_design_reference_catalog
|
||||
- Coverage mode: preserve visual quality rules and examples as generation guidance; do not collapse them into generic style advice.
|
||||
98
skills/lark-slides/references/svg-slides/workflow.md
Normal file
98
skills/lark-slides/references/svg-slides/workflow.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# SVG Slides Workflow
|
||||
|
||||
## Layer Boundary
|
||||
|
||||
This workflow owns local SVG deck generation and local validation. It does not call live APIs, choose a PPE lane, or prove backend acceptance of the generated payload.
|
||||
|
||||
The output is a local publish-ready bundle: standalone SVG slide files, source notes, optional assets, validation receipts, and a manifest. It is not published.
|
||||
|
||||
## Phase 1: Understand Request
|
||||
|
||||
Decide whether the user wants a new deck, a continuation of an existing deck, a repair pass, or a visual-quality pass. Clarify only when the target file, target slides, audience, delivery mode, or requested outcome is genuinely ambiguous.
|
||||
|
||||
Audience means the final viewer, not the creator. A specific audience can drive density and evidence style directly. Generic labels such as "users", "clients", or "team" are not specific enough for broad generation unless the user asks not to be interrupted.
|
||||
|
||||
## Phase 2: Settle Goal Audience Delivery
|
||||
|
||||
Settle three values before designing slides:
|
||||
|
||||
- `goal`: what the presentation should make the viewer understand or decide.
|
||||
- `audience`: who will read or watch it.
|
||||
- `delivery_mode`: `presented` when a speaker talks over it, `self_read` when it must stand alone.
|
||||
|
||||
Do not ask the user to pick tone, palette, density, or style. Those are inferred later by the design brief.
|
||||
|
||||
## Phase 3: Build Source Material
|
||||
|
||||
Broad topic-only requests require real source material. Search snippets, memory, and internal knowledge are not enough.
|
||||
|
||||
Collect full source text before drafting claims. Save a local research file with data points, claims, caveats, and source references. Every important claim or number used later must be traceable from `slide_content.md` back to this source material.
|
||||
|
||||
## Phase 4: Resolve Design Brief
|
||||
|
||||
Create a design brief after goal, audience, delivery mode, language, page count, and source material are known.
|
||||
|
||||
The brief must include:
|
||||
|
||||
- `narrative_spine`: the sequence logic and discipline of the deck.
|
||||
- `depth`: altitude, density, include/exclude rules, and main points per slide.
|
||||
- `tone`: writing and evidence style.
|
||||
- `visual_system`: color, typography, layout, imagery, material, and decoration direction.
|
||||
|
||||
The design brief is authoritative for the generated deck. Do not override it with generic taste while authoring pages.
|
||||
|
||||
## Phase 5: Confirm Outline
|
||||
|
||||
For broad topics, create an actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise use 8-12 substantive slides for normal decks, unless the user explicitly asked for a short deck.
|
||||
|
||||
When the user gave a detailed outline, use it. When the user reorders, removes, adds, or rewrites slides, the user's outline wins over the brief's `narrative_spine`.
|
||||
|
||||
## Phase 6: Write slide_content
|
||||
|
||||
Write `slide_content.md` before SVG authoring.
|
||||
|
||||
`slide_content.md` records the structure, slide roles, key material, data points, claims, quotes, and source references. It does not lock exact final sentences, image paths, chart layout, or final page composition.
|
||||
|
||||
## Phase 7: Lock Visual Direction And Plan Visuals
|
||||
|
||||
Translate `visual_system` into concrete deck-level style:
|
||||
|
||||
- `aesthetic_direction`: the design language and mood from the brief.
|
||||
- `color_palette`: consistent deck palette, expressed later as `rgb(...)` / `rgba(...)`.
|
||||
- `typography`: a stable display/body pairing that matches the brief's category and treatment.
|
||||
- `visual_assets`: per-slide image and chart needs, including aspect ratio and placement intent.
|
||||
|
||||
Unless the user explicitly requests no images, cover, section divider, and closing pages default to a high-impact hero image or generated visual. Record the intended asset, crop/aspect ratio, placement, and text-readability overlay treatment in `visual_assets`; do not leave these page roles as text-only by default.
|
||||
|
||||
Plan charts before writing slides. Any real quantitative series that supports a slide's point should use the chart workflow rather than a hand-drawn fake chart.
|
||||
|
||||
## Phase 8: Author SVG Pages
|
||||
|
||||
Each page is a complete standalone SVG document. Compose freely for the page's content logic. Do not stamp out a fixed template pattern.
|
||||
|
||||
For each page, record authoring intent before writing:
|
||||
|
||||
- the central idea;
|
||||
- the layout relationship being encoded;
|
||||
- visual assets used;
|
||||
- animation decision, or `static`;
|
||||
- expected validation risks.
|
||||
|
||||
Do not regenerate the whole deck structure after slide files have been authored. Add or remove pages through the existing-deck workflow.
|
||||
|
||||
## Output Bundle
|
||||
|
||||
The local bundle should contain:
|
||||
|
||||
- `slides/*.svg`: one standalone SVG slide per page;
|
||||
- `slide_content.md`: source-backed content plan;
|
||||
- `research_notes.md` when source material was gathered;
|
||||
- `resources/` for chart/image sidecars;
|
||||
- `manifest.json` from `svg_slides_bundle.mjs`;
|
||||
- `receipts/validate_svg_deck.json` from the static validator;
|
||||
- optional browser text-boundary receipt.
|
||||
|
||||
## Source Coverage
|
||||
|
||||
- Covers manifest sections: system_prompt_workflow, slide_outline_tool, activate_slides_edit_tool, incremental_processing
|
||||
- Coverage mode: preserve workflow semantics from the source while replacing product-specific tool names with local generation stages.
|
||||
84
skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs
Executable file
84
skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const targetArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const outIndex = args.indexOf("--out");
|
||||
const outPath = outIndex >= 0 ? args[outIndex + 1] : "";
|
||||
|
||||
if (!targetArg) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> [--out <json-path>]");
|
||||
}
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import("playwright"));
|
||||
} catch {
|
||||
fail("playwright is not installed; install it in a dev environment before browser text-boundary QA", 2);
|
||||
}
|
||||
|
||||
const target = path.resolve(targetArg);
|
||||
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
fail(`Slides directory not found: ${slidesDir}`);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort();
|
||||
if (!slideFiles.length) {
|
||||
fail(`No .svg files found in ${slidesDir}`);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 }, deviceScaleFactor: 1 });
|
||||
const results = [];
|
||||
|
||||
for (const file of slideFiles) {
|
||||
const abs = path.join(slidesDir, file);
|
||||
const svg = fs.readFileSync(abs, "utf8");
|
||||
await page.setContent(`<!doctype html><html><body style="margin:0">${svg}</body></html>`, { waitUntil: "load" });
|
||||
const problems = await page.evaluate(() => {
|
||||
return [...document.querySelectorAll("foreignObject")].flatMap((node, index) => {
|
||||
if (node.getAttribute("slide:role") !== "shape" || node.getAttribute("slide:shape-type") !== "text") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const box = node.getBoundingClientRect();
|
||||
const children = [...node.children];
|
||||
if (!children.length) {
|
||||
return [{ index: index + 1, reason: "empty_text_object" }];
|
||||
}
|
||||
|
||||
return children.map((child) => {
|
||||
const childBox = child.getBoundingClientRect();
|
||||
const overflowX = childBox.left < box.left - 0.5 || childBox.right > box.right + 0.5;
|
||||
const overflowY = childBox.top < box.top - 0.5 || childBox.bottom > box.bottom + 0.5;
|
||||
if (!overflowX && !overflowY) return null;
|
||||
return {
|
||||
index: index + 1,
|
||||
reason: "text_bounds_overflow",
|
||||
box: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
childBox: { x: childBox.x, y: childBox.y, width: childBox.width, height: childBox.height }
|
||||
};
|
||||
}).filter(Boolean);
|
||||
});
|
||||
});
|
||||
results.push({ file: path.relative(process.cwd(), abs), problemCount: problems.length, problems });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
const problemCount = results.reduce((sum, item) => sum + item.problemCount, 0);
|
||||
const report = { status: problemCount === 0 ? "passed" : "failed", problemCount, results };
|
||||
const json = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (outPath) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
||||
fs.writeFileSync(outPath, json);
|
||||
}
|
||||
process.stdout.write(json);
|
||||
process.exit(problemCount === 0 ? 0 : 1);
|
||||
70
skills/lark-slides/scripts/svg_slides_bundle.mjs
Executable file
70
skills/lark-slides/scripts/svg_slides_bundle.mjs
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function fail(message, code = 2) {
|
||||
console.error(message);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const deckArg = args.find((arg) => !arg.startsWith("--"));
|
||||
const titleIndex = args.indexOf("--title");
|
||||
const title = titleIndex >= 0 ? args[titleIndex + 1] : "";
|
||||
|
||||
if (!deckArg || !title) {
|
||||
fail("Usage: node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title <title>");
|
||||
}
|
||||
|
||||
const root = path.resolve(deckArg);
|
||||
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
fail(`Slides directory not found: ${slidesDir}`);
|
||||
}
|
||||
|
||||
const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
|
||||
const validate = spawnSync("node", [validator, root, "--json"], { encoding: "utf8" });
|
||||
if (!validate.stdout.trim()) {
|
||||
process.stderr.write(validate.stderr);
|
||||
process.exit(validate.status || 1);
|
||||
}
|
||||
|
||||
const receipt = JSON.parse(validate.stdout);
|
||||
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
||||
if (receipt.totalErrors !== 0) {
|
||||
fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort();
|
||||
|
||||
const pages = slideFiles.map((file, index) => {
|
||||
const abs = path.join(slidesDir, file);
|
||||
const raw = fs.readFileSync(abs);
|
||||
return {
|
||||
id: path.basename(file, ".svg"),
|
||||
index: index + 1,
|
||||
file: path.relative(root, abs).split(path.sep).join("/"),
|
||||
sha256: crypto.createHash("sha256").update(raw).digest("hex")
|
||||
};
|
||||
});
|
||||
|
||||
const manifest = {
|
||||
version: "svglide.manifest.v1",
|
||||
protocol: "svg-slides.v1",
|
||||
title,
|
||||
size: { width: 960, height: 540 },
|
||||
publish_ready: true,
|
||||
published: false,
|
||||
pages,
|
||||
receipts: {
|
||||
validate_svg_deck: "receipts/validate_svg_deck.json"
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ ok: true, manifest: path.join(root, "manifest.json"), pages: pages.length }, null, 2));
|
||||
49
skills/lark-slides/scripts/svg_slides_bundle_test.mjs
Normal file
49
skills/lark-slides/scripts/svg_slides_bundle_test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_bundle.mjs");
|
||||
|
||||
function tempDeck() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-bundle-"));
|
||||
fs.mkdirSync(path.join(root, "slides"));
|
||||
return root;
|
||||
}
|
||||
|
||||
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="bundle_slide" viewBox="0 0 960 540">
|
||||
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Bundle</p>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
test("bundle builder writes manifest and validation receipt", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
|
||||
const result = spawnSync("node", [script, root, "--title", "Bundle Test"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.version, "svglide.manifest.v1");
|
||||
assert.equal(manifest.protocol, "svg-slides.v1");
|
||||
assert.equal(manifest.title, "Bundle Test");
|
||||
assert.deepEqual(manifest.size, { width: 960, height: 540 });
|
||||
assert.equal(manifest.publish_ready, true);
|
||||
assert.equal(manifest.published, false);
|
||||
assert.equal(manifest.pages.length, 1);
|
||||
assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/);
|
||||
const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8"));
|
||||
assert.equal(receipt.totalErrors, 0);
|
||||
});
|
||||
|
||||
test("bundle builder rejects invalid SVG deck", () => {
|
||||
const root = tempDeck();
|
||||
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace("rgba(255,255,255,1)", "#fff"));
|
||||
const result = spawnSync("node", [script, root, "--title", "Invalid"], { encoding: "utf8" });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /not publish-ready/);
|
||||
assert.equal(fs.existsSync(path.join(root, "receipts", "validate_svg_deck.json")), true);
|
||||
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
|
||||
});
|
||||
98
skills/lark-slides/scripts/svg_slides_source_coverage_check.mjs
Executable file
98
skills/lark-slides/scripts/svg_slides_source_coverage_check.mjs
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const rootArg = process.argv.find((arg) => !arg.startsWith("--") && arg !== process.argv[1] && arg !== process.argv[0]);
|
||||
const root = path.resolve(rootArg || "skills/lark-slides/references/svg-slides");
|
||||
const json = process.argv.includes("--json");
|
||||
const manifestPath = path.join(root, "source", "split-manifest.json");
|
||||
|
||||
function readText(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function coverageBlock(markdown) {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => line.trim() === "## Source Coverage");
|
||||
if (start === -1) return "";
|
||||
const block = [];
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
if (/^#{1,2}\s+/.test(lines[i])) break;
|
||||
block.push(lines[i]);
|
||||
}
|
||||
return block.join("\n");
|
||||
}
|
||||
|
||||
function coverageIds(block) {
|
||||
const match = block.match(/^- Covers manifest sections:\s*(.+)$/m);
|
||||
if (!match) return [];
|
||||
return match[1].split(",").map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
errors.push(`missing manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const manifest = errors.length === 0 ? JSON.parse(readText(manifestPath)) : { sections: [] };
|
||||
const sectionsById = new Map(manifest.sections.map((section) => [section.id, section]));
|
||||
const seen = new Map();
|
||||
|
||||
if (fs.existsSync(root)) {
|
||||
for (const entry of fs.readdirSync(root)) {
|
||||
if (!entry.endsWith(".md")) continue;
|
||||
const filePath = path.join(root, entry);
|
||||
const block = coverageBlock(readText(filePath));
|
||||
if (!block) {
|
||||
errors.push(`${entry}: missing ## Source Coverage`);
|
||||
continue;
|
||||
}
|
||||
const ids = coverageIds(block);
|
||||
if (ids.length === 0) {
|
||||
errors.push(`${entry}: missing "- Covers manifest sections:" line`);
|
||||
continue;
|
||||
}
|
||||
for (const id of ids) {
|
||||
const section = sectionsById.get(id);
|
||||
if (!section) {
|
||||
errors.push(`${entry}: unknown manifest section "${id}"`);
|
||||
continue;
|
||||
}
|
||||
if (section.target !== entry) {
|
||||
errors.push(`${entry}: section "${id}" belongs to ${section.target}`);
|
||||
}
|
||||
const files = seen.get(id) || [];
|
||||
files.push(entry);
|
||||
seen.set(id, files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of manifest.sections) {
|
||||
const files = seen.get(section.id) || [];
|
||||
if (files.length === 0) {
|
||||
errors.push(`${section.id}: not covered by ${section.target}`);
|
||||
}
|
||||
if (files.length > 1) {
|
||||
errors.push(`${section.id}: covered multiple times by ${files.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
sectionCount: manifest.sections.length,
|
||||
coveredCount: seen.size,
|
||||
errors
|
||||
};
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else if (errors.length === 0) {
|
||||
console.log(`Source coverage OK: ${report.coveredCount}/${report.sectionCount} sections`);
|
||||
} else {
|
||||
console.error(`Source coverage failed: ${errors.length} errors`);
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
}
|
||||
|
||||
process.exit(errors.length === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = path.resolve("skills/lark-slides/scripts/svg_slides_source_coverage_check.mjs");
|
||||
|
||||
function tempRoot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-coverage-"));
|
||||
fs.mkdirSync(path.join(root, "source"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeManifest(root, sections) {
|
||||
fs.writeFileSync(path.join(root, "source", "split-manifest.json"), JSON.stringify({
|
||||
version: "svg-slides.split-manifest.v1",
|
||||
source: "source/full.debranded.md",
|
||||
sections
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function run(root) {
|
||||
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("passes when each manifest section is covered by its target file", () => {
|
||||
const root = tempRoot();
|
||||
writeManifest(root, [{ id: "workflow", lines: [1, 10], target: "workflow.md" }]);
|
||||
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: workflow\n");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).errors.length, 0);
|
||||
});
|
||||
|
||||
test("fails when a section is missing from Source Coverage", () => {
|
||||
const root = tempRoot();
|
||||
writeManifest(root, [{ id: "protocol", lines: [1, 10], target: "protocol.md" }]);
|
||||
fs.writeFileSync(path.join(root, "protocol.md"), "# Protocol\n\n## Source Coverage\n\n- Covers manifest sections: other\n");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.ok(report.errors.some((error) => error.includes("unknown manifest section")));
|
||||
assert.ok(report.errors.some((error) => error.includes("not covered")));
|
||||
});
|
||||
|
||||
test("fails when a section is declared by the wrong target file", () => {
|
||||
const root = tempRoot();
|
||||
writeManifest(root, [{ id: "visual", lines: [1, 10], target: "visual-design.md" }]);
|
||||
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: visual\n");
|
||||
const result = run(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.ok(JSON.parse(result.stdout).errors.some((error) => error.includes("belongs to visual-design.md")));
|
||||
});
|
||||
244
skills/lark-slides/scripts/validate_svg_deck.mjs
Executable file
244
skills/lark-slides/scripts/validate_svg_deck.mjs
Executable file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> [--json]");
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const json = args.includes("--json");
|
||||
const targetArg = args.find((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (!targetArg) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const target = path.resolve(targetArg);
|
||||
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
|
||||
|
||||
if (!fs.existsSync(slidesDir)) {
|
||||
console.error(`Slides directory not found: ${slidesDir}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const slideFiles = fs.readdirSync(slidesDir)
|
||||
.filter((file) => file.endsWith(".svg"))
|
||||
.sort()
|
||||
.map((file) => path.join(slidesDir, file));
|
||||
|
||||
if (!slideFiles.length) {
|
||||
console.error(`No .svg files found in: ${slidesDir}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function commandExists(name) {
|
||||
const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function checkXml(file, errors) {
|
||||
if (!commandExists("xmllint")) {
|
||||
errors.push({
|
||||
rule: "xml.valid",
|
||||
severity: "warn",
|
||||
message: "xmllint is unavailable; XML parser check skipped",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = spawnSync("xmllint", ["--noout", file], { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
errors.push({
|
||||
rule: "xml.valid",
|
||||
severity: "error",
|
||||
message: (result.stderr || result.stdout || "xmllint failed").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function firstElementAfterDefs(svg) {
|
||||
const rootOpen = svg.match(/<svg\b[^>]*>/);
|
||||
if (!rootOpen) return null;
|
||||
let inner = svg.slice(rootOpen.index + rootOpen[0].length, svg.lastIndexOf("</svg>")).trim();
|
||||
if (inner.startsWith("<defs")) {
|
||||
const end = inner.indexOf("</defs>");
|
||||
if (end === -1) return null;
|
||||
inner = inner.slice(end + "</defs>".length).trim();
|
||||
}
|
||||
return inner.match(/^<([a-zA-Z][\w:-]*)\b([^>]*)>/)?.[0] || null;
|
||||
}
|
||||
|
||||
function stripDefs(svg) {
|
||||
return svg.replace(/<defs\b[\s\S]*?<\/defs>/g, "");
|
||||
}
|
||||
|
||||
function attrValue(tag, name) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return tag.match(new RegExp(`${escaped}="([^"]*)"`))?.[1] || "";
|
||||
}
|
||||
|
||||
function isColorValueAllowed(value) {
|
||||
return /^(rgba?\([^)]*\)|url\(#[-\w]+\))$/.test(value.trim());
|
||||
}
|
||||
|
||||
function checkSlide(file) {
|
||||
const rel = path.relative(process.cwd(), file);
|
||||
const svg = fs.readFileSync(file, "utf8");
|
||||
const errors = [];
|
||||
|
||||
checkXml(file, errors);
|
||||
|
||||
const root = svg.match(/<svg\b[^>]*>/)?.[0] || "";
|
||||
if (!root) {
|
||||
errors.push({ rule: "svg.root", severity: "error", message: "missing <svg> root" });
|
||||
} else {
|
||||
if (!/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.xmlns", severity: "error", message: "missing SVG namespace" });
|
||||
}
|
||||
if (!/xmlns:slide="https:\/\/slides\.bytedance\.com\/ns"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.slide-xmlns", severity: "error", message: "missing slide namespace" });
|
||||
}
|
||||
if (!/slide:role="slide"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.slide-role", severity: "error", message: "root must have slide:role=\"slide\"" });
|
||||
}
|
||||
if (!/id="[^"]+"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.id", severity: "error", message: "root must have id" });
|
||||
}
|
||||
if (!/viewBox="0 0 960 540"/.test(root)) {
|
||||
errors.push({ rule: "svg.root.viewBox", severity: "error", message: "expected viewBox=\"0 0 960 540\"" });
|
||||
}
|
||||
}
|
||||
|
||||
if (/<presentation\b/.test(svg)) {
|
||||
errors.push({ rule: "svg.no-presentation-wrapper", severity: "error", message: "single slide file must not wrap with <presentation>" });
|
||||
}
|
||||
|
||||
const first = firstElementAfterDefs(svg);
|
||||
const backgroundCount = (svg.match(/slide:role="background"/g) || []).length;
|
||||
if (backgroundCount !== 1) {
|
||||
errors.push({ rule: "background.count", severity: "error", message: `expected exactly one background, found ${backgroundCount}` });
|
||||
}
|
||||
if (!first || !/^(<rect\b|<image\b)/.test(first) || !/slide:role="background"/.test(first)) {
|
||||
errors.push({ rule: "background.first-child", severity: "error", message: "first rendered child after optional <defs> must be the background" });
|
||||
}
|
||||
|
||||
const bodyNoDefs = stripDefs(svg);
|
||||
const forbidden = [
|
||||
{ rule: "forbid.style-block", re: /<style\b/, message: "slide SVG must not rely on <style> blocks" },
|
||||
{ rule: "forbid.css-class", re: /\bclass="/, message: "slide SVG must not rely on CSS classes" },
|
||||
{ rule: "forbid.div-wrapper", re: /<div\b/, message: "text foreignObject must not contain <div>" },
|
||||
{ rule: "forbid.section-wrapper", re: /<section\b/, message: "text foreignObject must not contain <section>" },
|
||||
{ rule: "forbid.svg-text", re: /<text\b/, message: "use foreignObject rich text, not SVG <text>" },
|
||||
{ rule: "forbid.svg-marker", re: /\bmarker-(start|end|mid)=|<marker\b/, message: "line arrowheads must use slide:* arrow attributes, not SVG marker" },
|
||||
{ rule: "forbid.legacy-fontSize", re: /\bfontSize="/, message: "text visual properties must be in style, not legacy fontSize attribute" },
|
||||
{ rule: "forbid.legacy-bold", re: /\bbold="/, message: "text visual properties must be in style, not legacy bold attribute" },
|
||||
];
|
||||
for (const item of forbidden) {
|
||||
if (item.re.test(bodyNoDefs)) {
|
||||
errors.push({ rule: item.rule, severity: "error", message: item.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of bodyNoDefs.matchAll(/\b(fill|stroke|stop-color)="([^"]+)"/g)) {
|
||||
const [, attr, value] = match;
|
||||
if (!isColorValueAllowed(value)) {
|
||||
errors.push({
|
||||
rule: "color.attr",
|
||||
severity: "error",
|
||||
message: `${attr} must be rgb(...), rgba(...), or url(#...); got ${JSON.stringify(value)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const match of bodyNoDefs.matchAll(/(?:^|;)\s*color\s*:\s*([^;"]+)/g)) {
|
||||
const value = match[1].trim();
|
||||
if (!/^rgba?\([^)]*\)$/.test(value)) {
|
||||
errors.push({
|
||||
rule: "color.css",
|
||||
severity: "error",
|
||||
message: `CSS color must be rgb(...) or rgba(...); got ${JSON.stringify(value)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const foreignObjects = [...svg.matchAll(/<foreignObject\b([^>]*)>([\s\S]*?)<\/foreignObject>/g)];
|
||||
for (const [index, match] of foreignObjects.entries()) {
|
||||
const attrText = match[1];
|
||||
const inner = match[2].trim();
|
||||
const label = `foreignObject #${index + 1}`;
|
||||
const isTextObject = /slide:role="shape"/.test(attrText) && /slide:shape-type="text"/.test(attrText);
|
||||
if (!isTextObject) continue;
|
||||
|
||||
for (const attr of ["x", "y", "width", "height"]) {
|
||||
if (!new RegExp(`\\b${attr}="[-0-9.]+`).test(attrText)) {
|
||||
errors.push({ rule: "text.geometry", severity: "error", message: `${label} missing numeric ${attr}` });
|
||||
}
|
||||
}
|
||||
|
||||
const styleText = attrValue(match[0], "style");
|
||||
if (!/font-size:\s*\d+(?:\.\d+)?px/.test(styleText)) {
|
||||
errors.push({ rule: "text.style.font-size", severity: "error", message: `${label} missing font-size with px suffix in style` });
|
||||
}
|
||||
if (!/color:\s*rgba?\(/.test(styleText)) {
|
||||
errors.push({ rule: "text.style.color", severity: "error", message: `${label} missing rgb/rgba color in style` });
|
||||
}
|
||||
|
||||
if (!/^<(p|ul|ol|h1|h2|h3|small)\b/.test(inner)) {
|
||||
errors.push({
|
||||
rule: "text.direct-child",
|
||||
severity: "error",
|
||||
message: `${label} first direct child must be p/ul/ol/h1/h2/h3/small, got ${inner.slice(0, 40) || "empty"}`,
|
||||
});
|
||||
}
|
||||
if (/<(div|section)\b/.test(inner)) {
|
||||
errors.push({ rule: "text.no-wrapper", severity: "error", message: `${label} contains an invalid wrapper element` });
|
||||
}
|
||||
if (/^([^<]|\s)+$/.test(inner)) {
|
||||
errors.push({ rule: "text.no-bare-text", severity: "error", message: `${label} contains bare text instead of xhtml children` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of bodyNoDefs.matchAll(/<line\b([^>]*)>/g)) {
|
||||
const attrs = match[1];
|
||||
if (!/slide:role="shape"/.test(attrs) || !/slide:shape-type="line"/.test(attrs)) {
|
||||
errors.push({ rule: "line.role", severity: "error", message: "line must carry slide:role=\"shape\" and slide:shape-type=\"line\"" });
|
||||
}
|
||||
if (!/\bstroke="rgba?\(/.test(attrs)) {
|
||||
errors.push({ rule: "line.stroke", severity: "error", message: "line must have rgb/rgba stroke" });
|
||||
}
|
||||
}
|
||||
|
||||
return { file: rel, errorCount: errors.filter((item) => item.severity === "error").length, errors };
|
||||
}
|
||||
|
||||
const results = slideFiles.map(checkSlide);
|
||||
const totalErrors = results.reduce((sum, result) => sum + result.errorCount, 0);
|
||||
const report = {
|
||||
target: path.relative(process.cwd(), target),
|
||||
slidesDir: path.relative(process.cwd(), slidesDir),
|
||||
slideCount: slideFiles.length,
|
||||
totalErrors,
|
||||
results,
|
||||
};
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
console.log(`SVG deck validation: ${report.target}`);
|
||||
console.log(`Slides: ${report.slideCount}`);
|
||||
console.log(`Errors: ${report.totalErrors}`);
|
||||
for (const result of results) {
|
||||
const status = result.errorCount ? "FAIL" : "PASS";
|
||||
console.log(`\n[${status}] ${result.file}`);
|
||||
for (const error of result.errors) {
|
||||
if (error.severity === "warn") {
|
||||
console.log(` WARN ${error.rule}: ${error.message}`);
|
||||
} else {
|
||||
console.log(` ${error.rule}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(totalErrors ? 1 : 0);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user