Compare commits

..

10 Commits

Author SHA1 Message Date
zhanghuanxu
21af86c8d6 docs: add slides timeline demo reference 2026-07-07 13:18:13 +08:00
zhanghuanxu
41f2d78cc5 docs: add slides screenshot QA checklist 2026-07-07 11:55:49 +08:00
zhanghuanxu
0a56be4671 docs: expand lark slides planning guidance 2026-07-06 20:23:40 +08:00
zhanghuanxu
9336f97738 docs: add slides chart demo reference 2026-07-06 20:07:47 +08:00
zhanghuanxu
d85b81c05d refactor:design idea 2026-07-06 19:06:22 +08:00
zhanghuanxu
0fc4c1a727 refactor:visual consistency 2026-07-06 17:21:17 +08:00
zhanghuanxu
241d284e82 docs: refine lark slides whiteboard guidance 2026-07-06 15:33:20 +08:00
zhanghuanxu
55013a60f3 refactor:slides design 2026-07-06 15:19:00 +08:00
zhanghuanxu
cc4e3c3eef refactor(slides):create slide and edit ppt template 2026-07-03 12:54:40 +08:00
zhanghuanxu
92f72ab699 docs: update lark slides design ideas 2026-07-02 20:04:52 +08:00
182 changed files with 2613 additions and 16164 deletions

View File

@@ -263,19 +263,13 @@ 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:
@@ -283,28 +277,7 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
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
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -319,22 +292,15 @@ 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"
@@ -344,24 +310,16 @@ 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: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
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
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
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

3
.gitignore vendored
View File

@@ -27,9 +27,6 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/

View File

@@ -2,71 +2,6 @@
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
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
@@ -1398,9 +1333,6 @@ 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
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

@@ -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/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
$(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
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -20,28 +20,13 @@ import (
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -69,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
@@ -92,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -113,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -140,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -153,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -166,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -232,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -251,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -270,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -287,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -312,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -343,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -383,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -424,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -463,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
@@ -498,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -564,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -615,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -671,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
@@ -736,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -756,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -775,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -806,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -827,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -845,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -874,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -895,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -914,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -932,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -949,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -966,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -989,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -1030,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1056,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})

View File

@@ -14,13 +14,11 @@ 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"
@@ -105,11 +103,6 @@ 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
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
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, "")
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd
}
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the same source as schema execution so completion candidates match
// what `schema` can resolve.
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runSchema resolves the path through the schema catalog and renders the
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)

View File

@@ -102,8 +102,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
@@ -165,7 +164,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater)
}
return doAutoUpdate(opts, io, cur, latest, detect, updater)
return doNpmUpdate(opts, io, cur, latest, updater)
}
// --- Output helpers ---
@@ -227,23 +226,12 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
emitSkillsTextHints(io, skillsResult)
return nil
}
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
@@ -251,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}
npmResult := install(latest)
npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined, pm),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
@@ -275,7 +263,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined, pm); hint != "" {
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
@@ -286,7 +274,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest, pm)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
@@ -316,33 +304,23 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil {
skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
}
emitSkillsTextHints(io, skillsResult)
return nil
}
func permissionHint(pmOutput, pm string) string {
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return ""
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
return ""
}
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

View File

@@ -57,27 +57,6 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -102,110 +81,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
@@ -391,9 +266,6 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
@@ -867,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
// Linux: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
@@ -877,25 +749,16 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied", "npm")
hint = permissionHint("EACCES: permission denied")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error", "npm"); got != "" {
if got := permissionHint("some other error"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}

View File

@@ -1,10 +0,0 @@
# Lark Slides Development Plans
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
约束:
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。

File diff suppressed because it is too large Load Diff

View File

@@ -72,28 +72,6 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories
| Category | When | Exit | Typed struct |

View File

@@ -77,10 +77,14 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -38,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -45,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := envvars.AgentTrace(); v != "" {
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -19,7 +19,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

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

View File

@@ -114,35 +114,8 @@ 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) {

View File

@@ -1,17 +0,0 @@
// 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") }

View File

@@ -6,7 +6,8 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for golden tests and schema lint.
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
@@ -17,14 +18,3 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -86,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
// After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,11 +72,9 @@ func hasEmbeddedServices() bool {
}
// testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "1.0.0",
Version: "test-1.0",
Services: []meta.Service{
{
Name: name,
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
}
func TestCacheHit_WithinTTL(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
}
func TestNetworkError_SilentDegradation(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", reg.Version)
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
}
}

View File

@@ -32,7 +32,6 @@ type InstallMethod int
const (
InstallNpm InstallMethod = iota
InstallPnpm
InstallManual
)
@@ -54,32 +53,22 @@ var (
// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
ResolvedPath string
NpmAvailable bool
PnpmAvailable bool
Method InstallMethod
ResolvedPath string
NpmAvailable bool
}
// CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool {
switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
return d.Method == InstallNpm && d.NpmAvailable
}
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string {
switch {
case d.Method == InstallNpm && !d.NpmAvailable:
if d.Method == InstallNpm && !d.NpmAvailable {
return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
}
return "not installed via npm or pnpm"
return "not installed via npm"
}
// NpmResult holds the result of an npm install or skills update execution.
@@ -103,7 +92,6 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
@@ -113,38 +101,17 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
}
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether the
// owning package manager is available for auto-update.
// DetectInstallMethod determines how the CLI was installed and whether
// npm is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil {
return u.DetectOverride()
}
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable()
if err != nil {
return DetectResult{Method: InstallManual}
@@ -153,54 +120,24 @@ func (u *Updater) detectInstallMethod() DetectResult {
if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe}
}
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual
if strings.Contains(resolved, "node_modules") {
if containsPnpmMarker(resolved) {
method = InstallPnpm
} else {
method = InstallNpm
}
method = InstallNpm
}
d := DetectResult{Method: method, ResolvedPath: resolved}
switch method {
case InstallNpm:
d.NpmAvailable = npmOnPath
case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
}
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
npmAvailable := false
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
}
}
return false
return DetectResult{
Method: method,
ResolvedPath: resolved,
NpmAvailable: npmAvailable,
}
}
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -226,29 +163,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
@@ -347,40 +261,19 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...)
}
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
npxPath, err := exec.LookPath("npx")
if err != nil {
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -371,147 +371,3 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}

View File

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

View File

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

View File

@@ -215,73 +215,6 @@ 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
@@ -304,23 +237,13 @@ 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() && 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"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi

View File

@@ -1,54 +0,0 @@
// 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,
};

View File

@@ -1,71 +0,0 @@
{
"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"
]
}

View File

@@ -1,224 +0,0 @@
#!/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,
};

View File

@@ -1,94 +0,0 @@
// 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/);
});

View File

@@ -4,7 +4,6 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
@@ -36,6 +35,33 @@ 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 = {
@@ -259,7 +285,13 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
return labelDomainsForPath(filePath)[0] || "";
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
}
async function detectNewShortcutDomain(files) {

View File

@@ -8,17 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
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
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp"
git_init() {

View File

@@ -67,26 +67,6 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
return true
}
}
return false
}
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
if err == nil || !attendeesIncludeRoom(attendees) {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
return err
}
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
}
var CalendarCreate = common.Shortcut{
Service: "calendar",
Command: "+create",
@@ -245,7 +225,6 @@ var CalendarCreate = common.Shortcut{
"need_notification": true,
})
if err != nil {
err = guideApprovalRoomReasonError(err, attendees)
// Rollback: delete the event
_, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
@@ -306,9 +285,6 @@ 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{}

View File

@@ -1,279 +0,0 @@
// 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
},
}

View File

@@ -673,76 +673,6 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
}
}
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_approval_room",
"summary": "Approval Room",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_approval_room/attendees",
Body: map[string]interface{}{
"code": codeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_approval_room",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Approval Room",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "omm_room1",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for approval room missing approval_reason, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for %T", err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
if p.Code != codeInvalidParamsWithDetail {
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
}
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
}
}
}
// When the add-attendees call fails AND the rollback DELETE also fails, the
// primary error stays the add failure (classification preserved) and the Hint
// must surface BOTH the rollback failure reason and the orphan event_id so the
@@ -2304,17 +2234,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
func TestShortcuts_Returns10(t *testing.T) {
func TestShortcuts_Returns9(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 10 {
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
if len(shortcuts) != 9 {
t.Fatalf("expected 9 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", "+get"} {
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
if !names[want] {
t.Errorf("missing shortcut %s", want)
}
@@ -3178,193 +3108,3 @@ 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)
}
}

View File

@@ -17,6 +17,5 @@ func Shortcuts() []common.Shortcut {
CalendarSuggestion,
CalendarMeeting,
CalendarSearchEvent,
CalendarGet,
}
}

View File

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

View File

@@ -1,261 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -1,340 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"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"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -27,17 +27,12 @@ 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 {
@@ -63,11 +58,6 @@ 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") {
@@ -125,11 +115,7 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
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)
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -246,248 +232,6 @@ 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)
@@ -877,34 +621,6 @@ 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 {
@@ -914,15 +630,6 @@ 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
@@ -943,31 +650,6 @@ 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('<')
@@ -992,25 +674,6 @@ 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 {
@@ -1031,18 +694,3 @@ 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("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -6,13 +6,11 @@ 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"
@@ -118,61 +116,6 @@ 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 {
@@ -464,119 +407,6 @@ 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)

View File

@@ -31,8 +31,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update":
return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default:
return docsSkillReadCommand
}
@@ -46,12 +44,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help"
case "update":
return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default:
return "lark-cli docs --help"
}
@@ -64,9 +56,6 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert,
DocMediaUpload,
DocMediaPreview,

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
@@ -29,8 +28,6 @@ const (
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
)
// driveImportExtToDocTypes defines which source file extensions can be imported
@@ -50,8 +47,6 @@ var driveImportExtToDocTypes = map[string][]string{
"pptx": {"slides"},
}
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
// driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct {
FilePath string
@@ -432,7 +427,11 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return status, true, nil
}
if status.Failed() {
return status, false, driveImportFailureError(status)
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
}
}
if !hadSuccessfulPoll && lastErr != nil {
@@ -441,40 +440,3 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return lastStatus, false, nil
}
func driveImportFailureError(status driveImportStatus) *errs.APIError {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
if code, ok := driveImportConcurrentOperationCode(msg); ok {
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
}
return apiErr
}
func driveImportConcurrentOperationCode(msg string) (int, bool) {
for _, code := range driveImportConcurrentOperationCodes {
codeText := strconv.Itoa(code)
for idx := strings.Index(msg, codeText); idx >= 0; {
end := idx + len(codeText)
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
return code, true
}
nextStart := idx + 1
next := strings.Index(msg[nextStart:], codeText)
if next < 0 {
break
}
idx = nextStart + next
}
}
return 0, false
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"errors"
"os"
"strconv"
"strings"
"testing"
@@ -212,82 +211,6 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
}
}
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
t.Parallel()
for _, code := range driveImportConcurrentOperationCodes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype != errs.SubtypeServerError {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
}
if problem.Code != code {
t.Fatalf("code = %d, want %d", problem.Code, code)
}
if !problem.Retryable {
t.Fatal("expected retryable error")
}
if problem.Hint != driveImportConcurrentOperationHint {
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
}
})
}
}
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg string
}{
{
name: "ordinary failure",
msg: "unsupported conversion",
},
{
name: "longer numeric code containing known code",
msg: "call CreateObjNode return error code, code: 12321401012, message:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: tt.msg,
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Code != 0 {
t.Fatalf("code = %d, want 0", problem.Code)
}
if problem.Retryable {
t.Fatal("expected non-concurrency failure to remain non-retryable")
}
if problem.Hint != "" {
t.Fatalf("hint = %q, want empty", problem.Hint)
}
})
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -184,7 +184,6 @@ var DrivePull = common.Shortcut{
var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0
aborted := false
items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability.
@@ -195,7 +194,7 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
if aborted {
if drivePullHasTerminalFailure(items) {
break
}
targetFile := remoteFiles[rel]
@@ -233,7 +232,6 @@ 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
}
@@ -300,7 +298,7 @@ var DrivePull = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
"aborted": aborted,
"aborted": drivePullHasTerminalFailure(items),
},
"items": items,
}
@@ -349,6 +347,15 @@ 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 {

View File

@@ -35,7 +35,6 @@ 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"`
@@ -49,7 +48,6 @@ 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
@@ -242,7 +240,6 @@ 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
@@ -269,7 +266,6 @@ 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
}
@@ -288,7 +284,7 @@ var DrivePush = common.Shortcut{
for _, rel := range localPaths {
localFile := localFiles[rel]
if uploadFailed && aborted {
if uploadFailed && drivePushHasTerminalFailure(items) {
break
}
@@ -305,7 +301,6 @@ 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
}
@@ -337,7 +332,6 @@ 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
}
@@ -356,7 +350,6 @@ 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
}
@@ -369,7 +362,6 @@ 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
}
@@ -415,15 +407,10 @@ 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
@@ -442,7 +429,7 @@ var DrivePush = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
"aborted": aborted,
"aborted": drivePushHasTerminalFailure(items),
},
"items": items,
}
@@ -580,7 +567,6 @@ 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,
@@ -627,10 +613,6 @@ 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:
@@ -644,9 +626,22 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
return decision
}
func drivePushIsAlreadyDeleted(err error) bool {
problem, ok := errs.ProblemOf(err)
return ok && problem.Code == 1061007
func drivePushHasTerminalFailure(items []drivePushItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
func driveTerminalBatchErrorClass(errorClass string) bool {
switch errorClass {
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
return true
default:
return false
}
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {

View File

@@ -732,65 +732,6 @@ 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())
@@ -1196,78 +1137,6 @@ 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())

View File

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

View File

@@ -268,7 +268,6 @@ 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.
@@ -287,21 +286,16 @@ 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 aborted {
if driveSyncHasTerminalFailure(items) {
break
}
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
item, _ := 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"})
@@ -310,7 +304,7 @@ var DriveSync = common.Shortcut{
// 2a. Pull new_remote files.
for _, entry := range newRemote {
if aborted {
if driveSyncHasTerminalFailure(items) {
break
}
targetFile, ok := pullRemoteFiles[entry.RelPath]
@@ -324,7 +318,6 @@ 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
}
@@ -336,7 +329,7 @@ var DriveSync = common.Shortcut{
// 2b. Push new_local files.
for _, entry := range newLocal {
if aborted {
if driveSyncHasTerminalFailure(items) {
break
}
localFile, ok := pushLocalFiles[entry.RelPath]
@@ -348,14 +341,9 @@ var DriveSync = common.Shortcut{
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
item, _ := 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)
@@ -364,7 +352,6 @@ 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
}
@@ -376,7 +363,7 @@ var DriveSync = common.Shortcut{
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
if aborted {
if driveSyncHasTerminalFailure(items) {
break
}
remoteFile := remoteFiles[entry.RelPath]
@@ -410,7 +397,6 @@ 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
}
@@ -429,14 +415,9 @@ var DriveSync = common.Shortcut{
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
item, _ := 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)
@@ -454,7 +435,6 @@ 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
}
@@ -523,7 +503,6 @@ 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
}
@@ -552,7 +531,7 @@ var DriveSync = common.Shortcut{
"pushed": pushed,
"skipped": skipped,
"failed": failed,
"aborted": aborted,
"aborted": driveSyncHasTerminalFailure(items),
},
"items": items,
}
@@ -598,6 +577,15 @@ 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.

View File

@@ -51,8 +51,9 @@ 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 +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
"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))
}
// hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,19 +465,14 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
// Inject ANSI escape + message ID to verify sanitization
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
out := stderr.String()
if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, 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)
if !strings.Contains(out, "msg-123") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
}
}

View File

@@ -1,482 +0,0 @@
// 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)
}
})
}
}

View File

@@ -1,141 +0,0 @@
// 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
}

View File

@@ -1,75 +0,0 @@
// 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
}

View File

@@ -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 mail:user_mailbox.folder:read",
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",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {

View File

@@ -1,283 +0,0 @@
// 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)
}
})
}

View File

@@ -10,8 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MailMessage,
MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread,
MailTriage,
MailWatch,

View File

@@ -715,15 +715,9 @@ 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 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.")
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
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

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,19 +30,27 @@ 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 {
spec, err := readMarkdownCreateSpec(runtime)
if err != nil {
return err
}
return validateMarkdownSpec(runtime, spec, true)
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)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readMarkdownCreateSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.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"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
@@ -64,9 +71,14 @@ var MarkdownCreate = common.Shortcut{
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readMarkdownCreateSpec(runtime)
if err != nil {
return err
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"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
@@ -103,139 +115,3 @@ 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 ""
}
}

View File

@@ -446,173 +446,6 @@ 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())

View File

@@ -28,12 +28,7 @@ import (
const minutesDetailLogPrefix = "[minutes +detail]"
// Error codes from the minutes API.
const (
minutesDetailProcessingCode = 2091003
minutesDetailNoReadPermissionCode = 2091005
minutesDetailWaitTimeoutDefault = 300
minutesDetailWaitIntervalDefault = 15
)
const minutesDetailNoReadPermissionCode = 2091005
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -45,31 +40,19 @@ 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 {
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)
})
data, err := 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 isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
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)
@@ -98,16 +81,10 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
needKeyword := runtime.Bool("keyword")
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
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)
})
artData, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
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)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
} else {
artifacts := make(map[string]any)
if needSummary {
@@ -156,78 +133,6 @@ 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
@@ -296,9 +201,6 @@ 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"))
@@ -380,15 +282,8 @@ var MinutesDetail = common.Shortcut{
for _, r := range results {
row := map[string]interface{}{"minute_token": r.MinuteToken}
if r.Error != "" {
if r.Status == "processing" {
row["status"] = "PROCESSING"
} else {
row["status"] = "FAIL"
}
row["status"] = "FAIL"
row["error"] = r.Error
if r.NextCommand != "" {
row["next_command"] = r.NextCommand
}
} else {
row["status"] = "OK"
row["title"] = r.Title

View File

@@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"sync"
@@ -109,17 +108,6 @@ 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)
@@ -184,34 +172,6 @@ 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
// ---------------------------------------------------------------------------
@@ -395,136 +355,6 @@ 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
// ---------------------------------------------------------------------------
@@ -548,36 +378,6 @@ 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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ package minutes
import (
"context"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
@@ -67,25 +65,8 @@ 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 ""
}

View File

@@ -143,28 +143,4 @@ 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)
}
})
}
}

View File

@@ -170,27 +170,6 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))

View File

@@ -6,7 +6,6 @@ package wiki
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,17 +26,3 @@ 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
}

View File

@@ -5,14 +5,12 @@ 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"
@@ -132,147 +130,6 @@ 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())
@@ -280,14 +137,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "7211568716812369922",
"space_id": "space_123",
"node_token": "wik_node_1",
"obj_token": "docx_1",
"obj_type": "docx",
@@ -297,7 +154,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
"has_child": true,
},
map[string]interface{}{
"space_id": "7211568716812369922",
"space_id": "space_123",
"node_token": "wik_node_2",
"obj_token": "docx_2",
"obj_type": "docx",
@@ -313,7 +170,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -354,14 +211,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
URL: "/open-apis/wiki/v2/spaces/space_123/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": "7211568716812369922",
"space_id": "space_123",
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
@@ -378,7 +235,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -429,7 +286,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "7211568716812369923",
"space_id": "space_personal_42",
"name": "My Library",
"space_type": "my_library",
},
@@ -439,14 +296,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/7211568716812369923/nodes",
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "7211568716812369923",
"space_id": "space_personal_42",
"node_token": "wik_personal_1",
"title": "Personal Note",
},
@@ -477,8 +334,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"] != "7211568716812369923" {
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
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"])
}
}
@@ -901,21 +758,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/7211568716812369922/nodes",
URL: "/open-apis/wiki/v2/spaces/space_123/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": "7211568716812369922", "node_token": "wik_1", "title": "First"},
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -945,14 +802,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/7211568716812369922/nodes",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "7211568716812369922",
"space_id": "space_123",
"node_token": "wik_1",
"obj_type": "docx",
"obj_token": "docx_1",
@@ -965,7 +822,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)

View File

@@ -48,19 +48,27 @@ 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 {
if _, err := readWikiNodeListSpec(runtime); err != nil {
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 {
return err
}
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiNodeListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
spaceID := strings.TrimSpace(runtime.Str("space-id"))
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if spec.ParentNodeToken != "" {
params["parent_node_token"] = spec.ParentNodeToken
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
params["parent_node_token"] = pt
}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
@@ -72,7 +80,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 spec.SpaceID == wikiMyLibrarySpaceID {
if spaceID == wikiMyLibrarySpaceID {
return d.
Desc("2-step orchestration: resolve my_library -> list nodes").
GET("/open-apis/wiki/v2/spaces/my_library").
@@ -83,17 +91,13 @@ var WikiNodeList = common.Shortcut{
Set("space_id", "<resolved_space_id>")
}
return d.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
Params(params).
Set("space_id", spec.SpaceID)
Set("space_id", spaceID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spec, err := readWikiNodeListSpec(runtime)
if err != nil {
return err
}
spaceID := spec.SpaceID
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// Resolve the my_library alias to the per-user real space_id before
// listing, so the subsequent request hits a concrete space endpoint.
@@ -106,7 +110,7 @@ var WikiNodeList = common.Shortcut{
spaceID = resolved
}
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
if err != nil {
return err
}
@@ -123,104 +127,10 @@ var WikiNodeList = common.Shortcut{
},
}
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) {
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID 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")
@@ -243,7 +153,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
}
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", wikiNodeListProblem(err, runtime)
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
@@ -267,36 +177,6 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
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"),

View File

@@ -6,7 +6,6 @@
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -195,7 +194,6 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 授权当前应用访问文档

View File

@@ -65,7 +65,7 @@
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `+message-trash --message-ids ... --yes`
3. 用户确认后 → `*.batch_trash`
## 身份选择:优先使用 user 身份
@@ -82,13 +82,12 @@
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. **整理**标签、已读/未读状态和移动文件夹优先用 `+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. **已读回执**
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. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -418,7 +417,7 @@ lark-cli mail +message --message-id <id>
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -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)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut**
## 身份
@@ -30,84 +30,28 @@ lark-cli calendar +agenda --as user
| Shortcut | 说明 |
|----------|------|
| `+agenda` | 查看日程安排(默认今天) |
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
| [`+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` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion` |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
| [`+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_idou_ 前缀可选默认当前登录用户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-update.md](references/lark-calendar-update.md) |
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 写操作反馈
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复日程操作(使用原日程 `event_id`),并按需处理例外
- **重复性日程例外Exception**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程 `event_id`
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
@@ -126,9 +70,7 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
| 按关键词搜索日程 | 本 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) |
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
## 任务类型分流
@@ -145,7 +87,7 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
## 会议室规则
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
@@ -153,45 +95,42 @@ lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
## 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>
```
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
### calendars
### 查询资源的方法列表以及方法的使用方式
- `create` — 创建共享日历
- `delete` — 删除共享日历
- `get` — 查询日历信息
- `list` — 查询日历列表
- `patch` — 更新日历信息
- `primary` — 查询用户主日历
- `search` — 搜索日历
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
- 查看方法的cli flag`lark-cli calendar <resource> <method> -h`
- 查看方法API参数`lark-cli schema calendar.<resource>.<method>`
### event.attendees
`<resource>``calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`
- `batch_delete` — 删除日程参与人
- `create` — 添加日程参与人
- `list` — 获取日程参与人列表
## 常用其他域命令
### events
```bash
# 搜索用户,更多参数详见 lark-contact
lark-cli contact +search-user --query <query> --as user
- `create` — 创建日程
- `delete` — 删除日程
- `get` — 获取日程
- `instance_view` — 查询日程视图
- `patch` — 更新日程
- `share_info` — 获取日程分享链接
# 搜索群聊,更多参数详见 lark-im
lark-cli im +chat-search --query <query> --as user
```
### freebusys
- `list` — 查询主日历日程忙闲信息
## 不在本 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)
- 会议室物理设施管理 → 管理员后台
**注意(强制性):**

View File

@@ -0,0 +1,78 @@
# 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:00end 取 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) -- 认证和全局参数

View File

@@ -1,9 +1,12 @@
# calendar +create
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建日程并按需邀请参会人。
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
## 推荐命令
```bash
@@ -35,16 +38,15 @@ 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。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)
@@ -53,16 +55,37 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
```bash
## 添加需要审批的会议室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.create
## 创建日程
lark-cli calendar events create \
--params '{"calendar_id":"<CALENDAR_ID>"}' \
--data '{
"summary": "技术分享CLI 架构设计",
"start_time": { "timestamp": "1741586400" },
"end_time": { "timestamp": "1741593600" }
}'
完整 API 命令的关键差异:
- 时间参数是 **Unix 秒字符串**(非 ISO 8601
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id
## 查看完整参数定义
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程
## 查看完整参数定义
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
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
## 参会人类型
@@ -78,5 +101,6 @@ lark-cli calendar event.attendees create \
## 参考
- [lark-calendar](../SKILL.md) -- skill 入口与路由
- [lark-calendar](../SKILL.md) -- 日历全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案

View File

@@ -0,0 +1,124 @@
# 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:00end 取 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

View File

@@ -1,90 +0,0 @@
# 重复性日程操作规范
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
## 关键概念
- **event_id 结构**`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime``0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性标题、时间、rrule、描述等都挂在本体上。
- **例外Exception**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}``originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
## 前置步骤(所有范围通用)
1. 通过 `+agenda``+search-event` 定位重复性日程,获取原重复性日程的 `event_id`
2. 通过 `events instance_view``+agenda` 列出实例,识别哪些是例外(`event_id``originalTime > 0` 的即为例外)。
3. 确认用户的操作范围。
## 编辑全部(更新时间)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
## 编辑全部(更新非时间字段)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
## 删除全部
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
## 编辑此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
## 删除此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
> 与「编辑此次及后续」的区别:不需要步骤 3创建新的重复性日程因为目标是删除后续而非替换。
## 仅此次
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`
## 用户意图映射
| 用户表达 | 操作范围 |
|----------|----------|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
| 「就改这一次」「只删这一次」 | 仅此次 |
| 未明确范围 | **必须询问用户** |
## 注意事项
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
## 参考
- [lark-calendar](../SKILL.md) — 日历全部命令
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,8 +1,11 @@
# calendar +room-find
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
需要的 scopes: ["calendar:calendar.free_busy:read"]
## 适用场景
- 已知一个或多个待选时间块,需要查找可用会议室
@@ -47,7 +50,7 @@ lark-cli calendar +room-find \
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意此参数只筛选楼层不可混入区域定位如“A区”或具体会议室号。 |
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名会议室号或编号区间时使用。 |
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室``木星``会议室 02` / `02会议室``02`。 |
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 |
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 |
@@ -64,7 +67,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"`
@@ -87,8 +90,9 @@ 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`,以防止会议室预约失败。不能直接按原规则继续。
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
@@ -98,7 +102,7 @@ lark-cli calendar +room-find \
| 字段名 | 说明 |
| :--- | :--- |
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
| `capacity` | 会议室最大容纳人数。 |
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
@@ -106,4 +110,4 @@ lark-cli calendar +room-find \
- [lark-calendar-create](lark-calendar-create.md)
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
- [lark-calendar](../SKILL.md) — skill 入口与路由
- [lark-calendar](../SKILL.md) — 日历完整 API

View File

@@ -1,8 +1,11 @@
# calendar +rsvp
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
需要的scopes: ["calendar:calendar.event:reply"]
## 命令
```bash
@@ -35,4 +38,5 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
## 参考
- [lark-calendar](../SKILL.md) -- skill 入口与路由
- [lark-calendar](../SKILL.md) -- 日历全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1,59 +0,0 @@
# 明确时间分支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#落地日程变更)。

View File

@@ -1,88 +0,0 @@
# 模糊时间 / 无时间信息分支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#落地日程变更)。

View File

@@ -1,95 +1,206 @@
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
## 执行摘要
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问
- **新建流先补默认值,编辑流先继承已定位日程信息。**
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程``这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
## 严禁行为
- **严禁在未读取对应子命令文档直接调用命令**
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md``lark-calendar-suggestion.md`)的情况下直接调用命令** 必须先阅读文档掌握最新参数要求与规范。
- **严禁在尚未判断新建还是编辑之前,就直接进入创建日程或查会议室动作。**
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求当成新建日程。** 这类请求必须先定位目标日程。
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
## 适用场景
- "帮我约个会" / "下周找时间和 XX 开会"
- "帮我订/找/搜索一个可用会议室"
- "明天下午3点约个日程"
- "把明天上午的日程加上 小明"
- "给下周一的周会换个会议室"
- "把这个日程改到明天下午,并加上学清 F201"
- 帮我约个会
- “下周找时间和 XX 开会”
- “帮我订个会议室”
- “帮我找/搜索一个可用的会议室”
- “帮我推荐一个我以前常用的会议室
- “查询明天下午可用的会议室”
- “明天下午3点约个日程/日历”
- “把明天上午的日程‘产品发布会’加上 小明
- “给下周一的周会换个会议室”
- “把这个日程改到明天下午,并加上学清 F201”
## 核心概念
- **会议室是日程的一种参与人attendee / resource不能脱离日程单独预定。**
- **预定或查找会议室,均需先确定时间块。**
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
- **会议室是日程的一种参与人attendee / resource不能脱离日程单独预定。**
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。
## CRITICAL 约束
- **在调用任何具体的 CLI 子命令(如 `+room-find``+suggestion``+freebusy``+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
## 任务类型判定
| 类型 | 典型语言信号 | 第一动作 |
|------|--------------|----------|
| 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
| 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `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`
- **标题**:根据上下文自动生成;如无法推断默认"会议"
- **参会人**:如未指定,默认仅用户自己
- **时长**:基于上下文推断;默认 30 分钟
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
### 2. 新建日程:智能推断默认
以下信息智能推断,减少频繁询问用户:
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 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)
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
```bash
lark-cli calendar +create \
@@ -103,20 +214,52 @@ 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"
```
落地规则:
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids`
- 需要会议室时,将选中的 `room_id` 写入参与人列表
规则:
- 新建日程时,可使用 `+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人)
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
```
## 参考
- [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-calendar-update.md](./lark-calendar-update.md)
- [SKILL.md](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)
- [lark-calendar](../SKILL.md)

View File

@@ -0,0 +1,29 @@
# 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`——日历不收录"即时会议",只查日程会漏。

View File

@@ -1,5 +1,6 @@
# calendar +suggestion
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
@@ -7,6 +8,8 @@
-**当用户需求涉及寻找时间块,且时间未完全确定**(如`今天``近三天``本周``下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
-**当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
需要的scopes: ["calendar:calendar.free_busy:read"]
## 命令
```bash
@@ -118,4 +121,5 @@ lark-cli calendar +suggestion \
## 参考
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
- [lark-calendar](../SKILL.md) — skill 入口与路由
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
- [lark-calendar](../SKILL.md) — 日历完整 API

View File

@@ -1,10 +1,13 @@
# calendar +update
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新既有日程字段,或独立增量添加/移除参会人和会议室。
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
需要的 scopes: ["calendar:calendar.event:update"]
## 推荐命令
```bash
@@ -40,7 +43,7 @@ lark-cli calendar +update \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复日程操作规范](lark-calendar-recurring.md) |
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
@@ -62,9 +65,9 @@ lark-cli calendar +update \
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行
- 更新重复性日程的某一次实例时,必须先通过 `+agenda``+search-event` 或实例视图定位该实例的 `event_id`
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
## 高级用法(完整 API 命令)
@@ -95,6 +98,8 @@ lark-cli calendar +update \
## 参考
- [lark-calendar](../SKILL.md) -- skill 入口与路由
- [lark-calendar](../SKILL.md) -- 日历全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [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) -- 查询忙闲

View File

@@ -1,11 +1,11 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
---
# docs
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
> **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML。
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML(可用 callout、grid、checkbox 等富 block
> - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
## 快速决策
@@ -39,14 +39,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型Mermaid 可由主 Agent 直接插入SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
@@ -70,7 +68,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |

View File

@@ -2,14 +2,14 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误。**
从 XML默认或 Markdown 内容创建一个新的飞书云文档。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
## 命令
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
## 参考
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档

View File

@@ -1,107 +0,0 @@
# docs history历史版本与回滚
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
## 安全流程
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id``revision_id``edit_time``name/description`)并确认后回滚。
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`
5.`+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
## 按 revision_id 或时间点回滚
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
4. 从最终匹配条目读取 `history_version_id``history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`
候选确认时使用类似格式:
```text
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
```
## 命令
```bash
# 列出历史版本
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
# 翻页
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
# 回滚到指定 history_version_id默认等待 30000ms
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
# 只发起任务,不等待
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
# 查询回滚任务状态
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
```
## 参数
| 命令 | 参数 | 必填 | 说明 |
|-|-|-|-|
| `+history-list` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
| `+history-revert` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
## 返回值要点
`+history-list` 返回:
```json
{
"entries": [
{
"revision_id": 42,
"history_version_id": "11",
"edit_time": "1780000000",
"type": 1,
"name": "版本名",
"description": "版本说明",
"editor_ids": ["ou_xxx"]
}
],
"has_more": true,
"page_token": "page_token"
}
```
`+history-revert` 返回:
```json
{
"task_id": "task_xxx",
"status": "running",
"history_version_id": "11",
"poll_after_ms": 10000
}
```
`+history-revert-status` 返回:
```json
{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": ["blk_xxx"]
}
```
`status` 可能是 `running``done``partial_failed``failed`。当状态是 `partial_failed``failed` 时,优先检查 `failed_block_tokens`

View File

@@ -1,113 +0,0 @@
# 飞书思维笔记Mindnote
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`
> [!IMPORTANT]
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 命令
```bash
# 先看命令帮助
lark-cli mindnotes nodes list --help
lark-cli mindnotes nodes create --help
# 读取节点列表
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
# 创建子节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
# 更新已有节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
```
## 参数
### `mindnotes nodes list`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
返回重点:`data.nodes` 中常见字段有 `node_id``parent_id``texts``notes``images``finish``highlight`
### `mindnotes nodes create`
命令参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
| `--data` | 是 | JSON 请求体 |
请求体字段:
| 字段 | 必填 | 说明 |
|------|------|------|
| `client_token` | 否 | 幂等 token建议写操作传入推荐使用时间戳或 UUID |
| `nodes` | 是 | 待创建或更新的节点数组 |
| `nodes[].node_id` | 否 | 节点 ID传入已有 `node_id` 时表示更新对应节点 |
| `nodes[].parent_id` | 否 | 父节点 ID创建子节点时传入 |
| `nodes[].texts` | 否 | 节点正文富文本数组 |
| `nodes[].notes` | 否 | 节点备注富文本数组 |
| `nodes[].images` | 否 | 节点图片列表 |
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
| `nodes[].finish` | 否 | 节点完成状态 |
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
```json
[{"element_type":"text","text":{"content":"节点内容"}}]
```
### 节点图片(`nodes[].images`
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
```bash
# 先上传图片,拿到 token
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
# 再把 token 写进节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
```
参数说明:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径 |
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
| `--parent-node` | 是 | 传 Mindnote 的 token |
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
## 推荐工作流
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先通过 token 类别判断。
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID避免重试时重复创建或重复更新。
> [!CAUTION]
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -3,8 +3,8 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误。**
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节
## 最佳实践
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
## 参考
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档

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