mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
Compare commits
18 Commits
docs-skill
...
codex/html
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da237cc73a | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Run dry-run E2E tests
|
- name: Run dry-run E2E tests
|
||||||
env:
|
env:
|
||||||
@@ -277,7 +283,28 @@ jobs:
|
|||||||
LARKSUITE_CLI_APP_ID: dry-run
|
LARKSUITE_CLI_APP_ID: dry-run
|
||||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||||
LARKSUITE_CLI_BRAND: feishu
|
LARKSUITE_CLI_BRAND: feishu
|
||||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||||
|
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||||
|
run: |
|
||||||
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
|
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||||
|
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||||
|
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||||
|
fi
|
||||||
|
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||||
|
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||||
|
fi
|
||||||
|
|
||||||
e2e-live:
|
e2e-live:
|
||||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||||
@@ -292,15 +319,22 @@ jobs:
|
|||||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Configure bot credentials
|
- name: Configure bot credentials
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
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"
|
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||||
@@ -310,16 +344,24 @@ jobs:
|
|||||||
- name: Run CLI E2E tests
|
- name: Run CLI E2E tests
|
||||||
env:
|
env:
|
||||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
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: |
|
run: |
|
||||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No live CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
packages="$E2E_LIVE_PACKAGES"
|
||||||
if [ -z "$packages" ]; then
|
if [ -z "$packages" ]; then
|
||||||
echo "No CLI E2E packages to test after exclusions."
|
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
echo "Live CLI E2E packages: $packages"
|
||||||
|
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||||
- name: Publish CLI E2E test report
|
- name: Publish CLI E2E test report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||||
with:
|
with:
|
||||||
name: CLI E2E Tests
|
name: CLI E2E Tests
|
||||||
|
|||||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v1.0.65] - 2026-07-03
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1371,6 +1398,7 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- 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.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.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.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
|||||||
bash scripts/resolve-changed-from.test.sh
|
bash scripts/resolve-changed-from.test.sh
|
||||||
bash scripts/ci-workflow.test.sh
|
bash scripts/ci-workflow.test.sh
|
||||||
bash scripts/semantic-review-workflow.test.sh
|
bash scripts/semantic-review-workflow.test.sh
|
||||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||||
|
|
||||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # Comma-separated values
|
--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
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--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
|
```bash
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
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()
|
t.Helper()
|
||||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
}
|
}
|
||||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||||
service.RegisterServiceCommands(rootCmd, f)
|
if catalog != nil {
|
||||||
|
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||||
|
} else {
|
||||||
|
service.RegisterServiceCommands(rootCmd, f)
|
||||||
|
}
|
||||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
return rootCmd
|
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) {
|
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv(envvars.CliAppID, "")
|
t.Setenv(envvars.CliAppID, "")
|
||||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
|||||||
|
|
||||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
catalog := strictModeFixtureCatalog()
|
||||||
|
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||||
|
|
||||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||||
})
|
})
|
||||||
|
|
||||||
if code != output.ExitValidation {
|
if code != output.ExitValidation {
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||||
// It uses the embedded source so completion candidates match what `schema`
|
// It uses the same source as schema execution so completion candidates match
|
||||||
// execution can resolve (both overlay-free).
|
// what `schema` can resolve.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
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) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the embedded catalog and renders the
|
// runSchema resolves the path through the schema catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.EmbeddedCatalog()
|
catalog := registry.SchemaCatalog()
|
||||||
|
if len(catalog.Services()) == 0 {
|
||||||
|
// No embedded metadata and the runtime fallback is empty too: offline
|
||||||
|
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||||
|
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||||
|
}
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
Long: `Update lark-cli to the latest version.
|
Long: `Update lark-cli to the latest version.
|
||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
|
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
|
pm := "npm"
|
||||||
|
install := updater.RunNpmInstall
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
pm = "pnpm"
|
||||||
|
install = updater.RunPnpmInstall
|
||||||
|
}
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := updater.RunNpmInstall(latest)
|
npmResult := install(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined),
|
"hint": permissionHint(combined, pm),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined); hint != "" {
|
if hint := permissionHint(combined, pm); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest)
|
hint := verificationFailureHint(updater, latest, pm)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
skillsPM := "npx"
|
||||||
|
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||||
|
skillsPM = "pnpm dlx"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(npmOutput string) string {
|
func permissionHint(pmOutput, pm string) string {
|
||||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
if pm == "pnpm" {
|
||||||
|
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||||
|
}
|
||||||
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
}
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
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))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
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 {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||||
|
t.Errorf("expected updated in output, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Human(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "via pnpm") {
|
||||||
|
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||||
|
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
|
t.Errorf("expected success message, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||||
|
)
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error exit")
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||||
|
t.Errorf("expected failure envelope, got: %s", out)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||||
|
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||||
|
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pnpm add -g") {
|
||||||
|
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
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) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
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).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied")
|
hint = permissionHint("EACCES: permission denied", "npm")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error"); got != "" {
|
if got := permissionHint("some other error", "npm"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`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
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
|||||||
// space→dot fallback covers domains where the two already coincide.
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
func commandFormResolver(service string) func(string) string {
|
func commandFormResolver(service string) func(string) string {
|
||||||
byForm := map[string]string{}
|
byForm := map[string]string{}
|
||||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||||
if svc.Name != service {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return func(h string) string {
|
return func(h string) string {
|
||||||
h = strings.TrimSpace(h)
|
h = strings.TrimSpace(h)
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -40,8 +38,6 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
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.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := AgentTraceValue(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AgentTraceValue / HeaderAgentTrace
|
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTraceValue(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " ")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, val)
|
|
||||||
if got := AgentTraceValue(); got != val {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
|
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentNameMaxLen = 128
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func AgentName() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentTrace() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" || len(v) > maxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "claude-code")
|
||||||
|
if got := AgentName(); got != "claude-code" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, " cursor ")
|
||||||
|
if got := AgentName(); got != "cursor" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\x01injected")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||||
|
t.Setenv(CliAgentName, longVal)
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTrace(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTrace(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " ")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(CliAgentTrace, longVal)
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(CliAgentTrace, val)
|
||||||
|
if got := AgentTrace(); got != val {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
|
|||||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
var driveCodeMeta = map[int]CodeMeta{
|
var driveCodeMeta = map[int]CodeMeta{
|
||||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
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
|
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||||
|
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||||
|
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||||
|
|||||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
|||||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
|
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
{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 {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
|||||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||||
|
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||||
|
// command-specific recovery guidance at the shortcut layer.
|
||||||
|
var wikiCodeMeta = map[int]CodeMeta{
|
||||||
|
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||||
|
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||||
|
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||||
@@ -6,8 +6,7 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||||
// and schema lint.
|
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||||
|
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||||
|
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||||
|
// nothing to resolve; the merged view is the only data such binaries have.
|
||||||
|
func SchemaCatalog() apicatalog.Catalog {
|
||||||
|
if len(EmbeddedServicesTyped()) > 0 {
|
||||||
|
return EmbeddedCatalog()
|
||||||
|
}
|
||||||
|
return RuntimeCatalog()
|
||||||
|
}
|
||||||
|
|||||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||||
|
// restores them (with a full state reset) on cleanup.
|
||||||
|
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resetInit()
|
||||||
|
orig := embeddedMetaJSON
|
||||||
|
embeddedMetaJSON = data
|
||||||
|
t.Cleanup(func() {
|
||||||
|
waitBackgroundRefresh()
|
||||||
|
embeddedMetaJSON = orig
|
||||||
|
resetInit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceEmbedded {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("embedded_svc"); !ok {
|
||||||
|
t.Fatal("expected embedded_svc from embedded metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||||
|
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||||
|
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||||
|
// Init seeds via sync fetch.
|
||||||
|
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(testEnvelopeJSON("remote_svc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
testMetaURL = ts.URL
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceRuntime {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("remote_svc"); !ok {
|
||||||
|
t.Fatal("expected remote_svc from runtime fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
// After a CLI upgrade the embedded data can be fresher than an old
|
||||||
|
// cache; an equal/older cache must not shadow it.
|
||||||
|
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||||
|
// marker, tagged with the given top-level data version and brand.
|
||||||
|
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||||
|
t.Helper()
|
||||||
|
cDir := filepath.Join(dir, "cache")
|
||||||
|
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := MergedRegistry{
|
||||||
|
Version: version,
|
||||||
|
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(reg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||||
|
mData, _ := json.Marshal(cm)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||||
|
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||||
|
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||||
|
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||||
|
t.Helper()
|
||||||
|
embedded, _ := json.Marshal(MergedRegistry{
|
||||||
|
Version: embeddedVer,
|
||||||
|
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||||
|
})
|
||||||
|
swapEmbeddedMeta(t, embedded)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||||
|
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||||
|
InitWithBrand(core.BrandFeishu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, ok := ServiceTyped(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service %q not loaded", name)
|
||||||
|
}
|
||||||
|
return svc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "2.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "2.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "not-a-semver")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||||
|
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||||
|
// plugin builds without compiled meta_data.json still get remote data.
|
||||||
|
initWithCache(t, "0.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// 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 {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "test-1.0",
|
Version: "1.0.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "test-1.0" {
|
if reg.Version != "1.0.0" {
|
||||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
|
InstallPnpm
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,22 +54,32 @@ var (
|
|||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
type DetectResult struct {
|
type DetectResult struct {
|
||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
|
PnpmAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
return d.Method == InstallNpm && d.NpmAvailable
|
switch d.Method {
|
||||||
|
case InstallNpm:
|
||||||
|
return d.NpmAvailable
|
||||||
|
case InstallPnpm:
|
||||||
|
return d.PnpmAvailable
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
switch {
|
||||||
|
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
|
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||||
|
return "installed via pnpm, but pnpm is not available in PATH"
|
||||||
}
|
}
|
||||||
return "not installed via npm"
|
return "not installed via npm or pnpm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
PnpmInstallOverride func(version string) *NpmResult
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -101,17 +113,38 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
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.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether
|
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||||
// npm is available for auto-update.
|
// owning package manager is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
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()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
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
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
method = InstallNpm
|
if containsPnpmMarker(resolved) {
|
||||||
}
|
method = InstallPnpm
|
||||||
|
} else {
|
||||||
npmAvailable := false
|
method = InstallNpm
|
||||||
if method == InstallNpm {
|
|
||||||
if _, err := exec.LookPath("npm"); err == nil {
|
|
||||||
npmAvailable = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||||
return DetectResult{
|
switch method {
|
||||||
Method: method,
|
case InstallNpm:
|
||||||
ResolvedPath: resolved,
|
d.NpmAvailable = npmOnPath
|
||||||
NpmAvailable: npmAvailable,
|
case InstallPnpm:
|
||||||
|
d.PnpmAvailable = pnpmOnPath
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||||
|
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||||
|
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||||
|
// whose resolved path runs through pnpm's home directory (e.g.
|
||||||
|
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||||
|
// followed by "store". Matching only these two shapes (rather than any bare
|
||||||
|
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||||
|
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||||
|
// classification is OS-independent and unit-testable anywhere.
|
||||||
|
func containsPnpmMarker(p string) bool {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == ".pnpm" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
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 {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
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 {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
npxPath, err := exec.LookPath("npx")
|
det := u.DetectInstallMethod()
|
||||||
|
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||||
|
binPath, err := exec.LookPath(launcher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainsPnpmMarker(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Classic virtual-store layout (.pnpm segment).
|
||||||
|
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||||
|
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||||
|
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||||
|
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/usr/local/bin/lark-cli", false},
|
||||||
|
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||||
|
// "pnpm" immediately followed by "store".
|
||||||
|
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||||
|
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||||
|
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsPnpmMarker(c.path); got != c.want {
|
||||||
|
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||||
|
u := &Updater{DetectOverride: nil}
|
||||||
|
u.DetectOverride = func() DetectResult {
|
||||||
|
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||||
|
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||||
|
}
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm {
|
||||||
|
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||||
|
}
|
||||||
|
if !got.PnpmAvailable {
|
||||||
|
t.Errorf("PnpmAvailable = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||||
|
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||||
|
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||||
|
}
|
||||||
|
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||||
|
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||||
|
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm available should CanAutoUpdate")
|
||||||
|
}
|
||||||
|
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualReason_Pnpm(t *testing.T) {
|
||||||
|
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||||
|
t.Errorf("pnpm reason = %q", got)
|
||||||
|
}
|
||||||
|
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||||
|
t.Errorf("manual reason = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||||
|
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
got := u.RunPnpmInstall("2.0.0")
|
||||||
|
if got.Err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", got.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||||
|
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||||
|
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInvocation(t *testing.T) {
|
||||||
|
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method InstallMethod
|
||||||
|
pnpmAvailable bool
|
||||||
|
args []string
|
||||||
|
wantLauncher string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||||
|
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||||
|
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||||
|
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||||
|
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||||
|
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||||
|
if gotLauncher != c.wantLauncher {
|
||||||
|
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||||
|
}
|
||||||
|
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||||
|
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||||
|
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||||
|
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||||
|
// stays consistent with what was detected — and reported — before the update.
|
||||||
|
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||||
|
u := New()
|
||||||
|
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||||
|
u.detectCache = &cached
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||||
|
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.65",
|
"version": "1.0.66",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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" ||
|
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||||
! grep -Fq "contents: read" <<<"$section" ||
|
! grep -Fq "contents: read" <<<"$section" ||
|
||||||
! grep -Fq "checks: write" <<<"$section"; then
|
! grep -Fq "checks: write" <<<"$section"; then
|
||||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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"
|
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
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"
|
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||||
|
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||||
|
|
||||||
|
function normalizeRepoPath(input) {
|
||||||
|
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||||
|
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||||
|
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
|
||||||
|
function findPathMapping(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function e2eDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFullFallback(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkippablePath(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
const basename = path.posix.basename(normalized);
|
||||||
|
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||||
|
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||||
|
|| (domainMap.skipFilenames || []).includes(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
domainMap,
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
labelDomainsForPath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
};
|
||||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"pathMappings": [
|
||||||
|
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||||
|
|
||||||
|
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||||
|
],
|
||||||
|
"fullFallbackPrefixes": [
|
||||||
|
"shortcuts/common/",
|
||||||
|
"cmd/",
|
||||||
|
"internal/",
|
||||||
|
"pkg/",
|
||||||
|
"extension/",
|
||||||
|
"registry/",
|
||||||
|
"go.mod",
|
||||||
|
"go.sum",
|
||||||
|
"Makefile",
|
||||||
|
".github/workflows/",
|
||||||
|
"scripts/"
|
||||||
|
],
|
||||||
|
"skipPrefixes": [
|
||||||
|
"docs/",
|
||||||
|
".changeset/"
|
||||||
|
],
|
||||||
|
"skipSuffixes": [
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".txt",
|
||||||
|
".rst"
|
||||||
|
],
|
||||||
|
"skipFilenames": [
|
||||||
|
"readme.md",
|
||||||
|
"readme.zh.md",
|
||||||
|
"changelog.md",
|
||||||
|
"license",
|
||||||
|
"cla.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const {
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
} = require("./domain-map");
|
||||||
|
|
||||||
|
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||||
|
process.chdir(ROOT);
|
||||||
|
|
||||||
|
function execLines(command, args) {
|
||||||
|
return execFileSync(command, args, { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulePath() {
|
||||||
|
return execLines("go", ["list", "-m"])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootPackage(moduleName) {
|
||||||
|
return `${moduleName}/tests/cli_e2e`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allLivePackages(moduleName) {
|
||||||
|
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||||
|
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||||
|
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allDryPackages(moduleName) {
|
||||||
|
return allLivePackages(moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainExistsCache = new Map();
|
||||||
|
|
||||||
|
function domainExists(domain) {
|
||||||
|
if (domainExistsCache.has(domain)) {
|
||||||
|
return domainExistsCache.get(domain);
|
||||||
|
}
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||||
|
exists = true;
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
domainExistsCache.set(domain, exists);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChangedFiles() {
|
||||||
|
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||||
|
if (changedFilesPath) {
|
||||||
|
return fs.readFileSync(changedFilesPath, "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(normalizeRepoPath)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||||
|
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDomain(domains, domain) {
|
||||||
|
if (domain && domainExists(domain)) {
|
||||||
|
domains.add(domain);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPath(filePath, domains) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
if (!normalized) return { matched: false };
|
||||||
|
|
||||||
|
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||||
|
if (e2eMatch) {
|
||||||
|
const domain = e2eMatch[1];
|
||||||
|
if (domain === "demo") return { matched: false };
|
||||||
|
if (domainExists(domain)) {
|
||||||
|
addDomain(domains, domain);
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||||
|
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesFullFallback(normalized)) {
|
||||||
|
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedDomains = e2eDomainsForPath(normalized);
|
||||||
|
if (mappedDomains.length > 0) {
|
||||||
|
const missingDomains = [];
|
||||||
|
for (const domain of mappedDomains) {
|
||||||
|
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||||
|
}
|
||||||
|
if (missingDomains.length > 0) {
|
||||||
|
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||||
|
}
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findPathMapping(normalized)) {
|
||||||
|
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||||
|
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
|
||||||
|
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDomains(changedFiles) {
|
||||||
|
const moduleName = modulePath();
|
||||||
|
const rootDryPackage = rootPackage(moduleName);
|
||||||
|
if (changedFiles === null) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: "non-pull_request run or unavailable diff",
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = new Set();
|
||||||
|
let matchedRelevant = false;
|
||||||
|
let fullReason = "";
|
||||||
|
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
const result = classifyPath(file, domains);
|
||||||
|
if (result.matched) matchedRelevant = true;
|
||||||
|
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullReason) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: fullReason,
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRelevant && domains.size > 0) {
|
||||||
|
const sortedDomains = [...domains].sort();
|
||||||
|
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||||
|
return {
|
||||||
|
mode: "subset",
|
||||||
|
reason: "business domain changes",
|
||||||
|
domains: sortedDomains,
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: packages,
|
||||||
|
livePackages: packages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "skip",
|
||||||
|
reason: "docs-only or no live CLI E2E impact",
|
||||||
|
domains: [],
|
||||||
|
dryRootPackage: "",
|
||||||
|
dryPackages: [],
|
||||||
|
livePackages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(resolved) {
|
||||||
|
const values = {
|
||||||
|
mode: resolved.mode,
|
||||||
|
reason: resolved.reason,
|
||||||
|
domains: resolved.domains.join(","),
|
||||||
|
dry_root_package: resolved.dryRootPackage,
|
||||||
|
dry_packages: resolved.dryPackages.join(" "),
|
||||||
|
live_packages: resolved.livePackages.join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||||
|
console.log(lines.join("\n"));
|
||||||
|
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
emit(resolveDomains(readChangedFiles()));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
classifyPath,
|
||||||
|
readChangedFiles,
|
||||||
|
resolveDomains,
|
||||||
|
};
|
||||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||||
|
|
||||||
|
function parseOutput(raw) {
|
||||||
|
const result = {};
|
||||||
|
for (const line of raw.trim().split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDomains(files) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||||
|
const file = path.join(dir, "changed.txt");
|
||||||
|
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||||
|
try {
|
||||||
|
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||||
|
cwd: path.join(__dirname, ".."),
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("maps shortcut changes to one business domain package", () => {
|
||||||
|
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "im");
|
||||||
|
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||||
|
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps doc shortcuts to docs package", () => {
|
||||||
|
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "docs");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps direct e2e domain package changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "drive");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for shared e2e harness changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for runtime changes", () => {
|
||||||
|
const output = runDomains(["cmd/root.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared\/runtime path changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips docs-only changes", () => {
|
||||||
|
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||||
|
assert.equal(output.mode, "skip");
|
||||||
|
assert.equal(output.domains, "");
|
||||||
|
assert.equal(output.dry_root_package, "");
|
||||||
|
assert.equal(output.live_packages, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses shared map for skill domain changes", () => {
|
||||||
|
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "sheets");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full when a mapped path has no e2e package", () => {
|
||||||
|
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { labelDomainsForPath } = require("../domain-map");
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants & Configuration
|
// Constants & Configuration
|
||||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
|||||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
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 SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||||
|
|
||||||
const CLASS_STANDARDS = {
|
const CLASS_STANDARDS = {
|
||||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
|||||||
|
|
||||||
// Get business domain label based on CODEOWNERS path mapping
|
// Get business domain label based on CODEOWNERS path mapping
|
||||||
function getBusinessDomain(filePath) {
|
function getBusinessDomain(filePath) {
|
||||||
const normalized = normalizePath(filePath);
|
return labelDomainsForPath(filePath)[0] || "";
|
||||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
|
||||||
if (normalized.startsWith(prefix)) {
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectNewShortcutDomain(files) {
|
async function detectNewShortcutDomain(files) {
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||||
|
|
||||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
|
||||||
|
cleanup_tmp() {
|
||||||
|
local attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -rf "$tmp" && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_tmp EXIT
|
||||||
mkdir -p "$tmp"
|
mkdir -p "$tmp"
|
||||||
|
|
||||||
git_init() {
|
git_init() {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
existing := map[string]bool{}
|
existing := map[string]bool{}
|
||||||
token := ""
|
token := ""
|
||||||
for {
|
for {
|
||||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
params := map[string]interface{}{"page_size": 100}
|
||||||
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
|||||||
|
|
||||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
statusParams := map[string]interface{}{}
|
||||||
|
if env != "" {
|
||||||
|
statusParams["env"] = env
|
||||||
|
}
|
||||||
|
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
|||||||
|
|
||||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"tables": strings.Join(tables, ","),
|
"tables": strings.Join(tables, ","),
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Enable table audit").
|
Desc("Enable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
|||||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||||
defer stop()
|
defer stop()
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||||
stop()
|
stop()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appAuditSetPath(appID)).
|
POST(appAuditSetPath(appID)).
|
||||||
Desc("Disable table audit").
|
Desc("Disable table audit").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
|||||||
}
|
}
|
||||||
table := strings.TrimSpace(rctx.Str("table"))
|
table := strings.TrimSpace(rctx.Str("table"))
|
||||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||||
map[string]interface{}{"env": dbEnv(rctx)},
|
dbEnvParams(rctx, map[string]interface{}{}),
|
||||||
map[string]interface{}{"table": table, "enabled": false})
|
map[string]interface{}{"table": table, "enabled": false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, dbAuditSetHint)
|
return withAppsHint(err, dbAuditSetHint)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
|||||||
|
|
||||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||||
params["table"] = t
|
params["table"] = t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
|||||||
|
|
||||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
addStr := func(flag, key string) {
|
addStr := func(flag, key string) {
|
||||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||||
params[key] = v
|
params[key] = v
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
{Name: "table", Desc: "source table", Required: true},
|
{Name: "table", Desc: "source table", Required: true},
|
||||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDataExportPath(appID)).
|
GET(appDataExportPath(appID)).
|
||||||
Desc("Export Miaoda app table data (raw bytes)").
|
Desc("Export Miaoda app table data (raw bytes)").
|
||||||
Params(map[string]interface{}{
|
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
"table": strings.TrimSpace(rctx.Str("table")),
|
||||||
"format": format, "limit": rctx.Int("limit"),
|
"format": format, "limit": rctx.Int("limit"),
|
||||||
})
|
}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||||
|
|
||||||
|
exportQuery := larkcore.QueryParams{
|
||||||
|
"table": []string{table},
|
||||||
|
"format": []string{format},
|
||||||
|
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||||
|
}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
exportQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodGet,
|
HttpMethod: http.MethodGet,
|
||||||
ApiPath: appDataExportPath(appID),
|
ApiPath: appDataExportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{
|
QueryParams: exportQuery,
|
||||||
"env": []string{dbEnv(rctx)},
|
|
||||||
"table": []string{table},
|
|
||||||
"format": []string{format},
|
|
||||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
|||||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
params := map[string]interface{}{"page_size": 1}
|
||||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
if env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
|
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
POST(appDataImportPath(appID)).
|
POST(appDataImportPath(appID)).
|
||||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
|||||||
fd.AddField("file_name", fileName)
|
fd.AddField("file_name", fileName)
|
||||||
fd.AddFile("file", bytes.NewReader(content))
|
fd.AddFile("file", bytes.NewReader(content))
|
||||||
|
|
||||||
|
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
importQuery["env"] = []string{env}
|
||||||
|
}
|
||||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodPost,
|
HttpMethod: http.MethodPost,
|
||||||
ApiPath: appDataImportPath(appID),
|
ApiPath: appDataImportPath(appID),
|
||||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
QueryParams: importQuery,
|
||||||
Body: fd,
|
Body: fd,
|
||||||
}, larkcore.WithFileUpload())
|
}, larkcore.WithFileUpload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||||
|
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||||
|
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||||
|
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
API []struct {
|
||||||
|
Params map[string]interface{} `json:"params"`
|
||||||
|
} `json:"api"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||||
|
p := env.API[0].Params
|
||||||
|
if _, ok := p["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||||
|
}
|
||||||
|
if p["table"] != "orders" {
|
||||||
|
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||||
chdirTemp(t)
|
chdirTemp(t)
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||||||
|
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||||||
|
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||||||
|
// 交由下面真实 apply 统一报同样的业务错。
|
||||||
|
pending := 0
|
||||||
|
var previewFrom, previewTo string
|
||||||
|
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||||||
|
pending = len(projectMigrationChanges(preview["changes"]))
|
||||||
|
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||||||
|
}
|
||||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
return withAppsHint(err, dbEnvMigrateHint)
|
return withAppsHint(err, dbEnvMigrateHint)
|
||||||
}
|
}
|
||||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||||
|
if from == "" {
|
||||||
|
from = previewFrom
|
||||||
|
}
|
||||||
|
if to == "" {
|
||||||
|
to = previewTo
|
||||||
|
}
|
||||||
taskID := common.GetString(submit, "task_id")
|
taskID := common.GetString(submit, "task_id")
|
||||||
applied := intFromAny(submit["changes_applied"])
|
applied := intFromAny(submit["changes_applied"])
|
||||||
if applied == 0 {
|
if applied == 0 {
|
||||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
|||||||
applied = n
|
applied = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||||
|
if applied == 0 && pending > 0 {
|
||||||
|
applied = pending
|
||||||
|
}
|
||||||
stop() // clear spinner before printing the result
|
stop() // clear spinner before printing the result
|
||||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
|||||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||||
|
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "POST", URL: dbEnvMigrateURL,
|
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||||
})
|
})
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
|||||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||||
|
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "POST", URL: dbEnvMigrateURL,
|
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||||
})
|
})
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||||
|
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||||
|
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||||
|
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||||
|
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
API []struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Params map[string]interface{} `json:"params"`
|
||||||
|
} `json:"api"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||||
|
a := env.API[0]
|
||||||
|
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||||
|
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||||
|
}
|
||||||
|
if _, ok := a.Params["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
|||||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||||
Input: []string{common.Stdin}},
|
Input: []string{common.Stdin}},
|
||||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
|||||||
//
|
//
|
||||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"transactional": false,
|
"transactional": false,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET(appDbQuotaPath(appID)).
|
GET(appDbQuotaPath(appID)).
|
||||||
Desc("Get Miaoda app database storage usage").
|
Desc("Get Miaoda app database storage usage").
|
||||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
appID, err := requireAppID(rctx.Str("app-id"))
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, appIDListHint)
|
return withAppsHint(err, appIDListHint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
|||||||
Scopes: []string{"spark:app:write"},
|
Scopes: []string{"spark:app:write"},
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||||
},
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return normalizeTimeFlags(rctx, "target")
|
return normalizeTimeFlags(rctx, "target")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||||
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
Scopes: []string{"spark:app:write"},
|
Scopes: []string{"spark:app:write"},
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||||
},
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return normalizeTimeFlags(rctx, "target")
|
return normalizeTimeFlags(rctx, "target")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||||
|
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
target := rctx.Str("target")
|
target := rctx.Str("target")
|
||||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return withAppsHint(err, dbRecoveryHint)
|
return withAppsHint(err, dbRecoveryHint)
|
||||||
}
|
}
|
||||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
}
|
}
|
||||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||||
},
|
},
|
||||||
func(d map[string]interface{}) (bool, error) {
|
func(d map[string]interface{}) (bool, error) {
|
||||||
switch strings.ToLower(common.GetString(d, "status")) {
|
switch strings.ToLower(common.GetString(d, "status")) {
|
||||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
|||||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||||
defer stop()
|
defer stop()
|
||||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, withAppsHint(err, dbRecoveryHint)
|
return nil, withAppsHint(err, dbRecoveryHint)
|
||||||
}
|
}
|
||||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
|||||||
}
|
}
|
||||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||||
func() (map[string]interface{}, error) {
|
func() (map[string]interface{}, error) {
|
||||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||||
},
|
},
|
||||||
func(d map[string]interface{}) (bool, error) {
|
func(d map[string]interface{}) (bool, error) {
|
||||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
|||||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||||
arr, _ := preview["changes"].([]interface{})
|
arr, _ := preview["changes"].([]interface{})
|
||||||
changes := make([]recoveryChange, 0, len(arr))
|
raw := make([]recoveryChange, 0, len(arr))
|
||||||
for _, it := range arr {
|
for _, it := range arr {
|
||||||
m, ok := it.(map[string]interface{})
|
m, ok := it.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
changes = append(changes, recoveryChange{
|
raw = append(raw, recoveryChange{
|
||||||
Table: common.GetString(m, "table"),
|
Table: common.GetString(m, "table"),
|
||||||
Inserted: m["inserted"],
|
Inserted: m["inserted"],
|
||||||
Deleted: m["deleted"],
|
Deleted: m["deleted"],
|
||||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
|||||||
DroppedAt: common.GetString(m, "dropped_at"),
|
DroppedAt: common.GetString(m, "dropped_at"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
tablesAffected := intFromAny(preview["tables_affected"])
|
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||||
if tablesAffected == 0 {
|
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||||
tablesAffected = len(changes)
|
// 两行 + tables_affected 翻倍。
|
||||||
|
hasSchema := map[string]bool{}
|
||||||
|
for _, c := range raw {
|
||||||
|
if c.Action != "" {
|
||||||
|
hasSchema[c.Table] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes := make([]recoveryChange, 0, len(raw))
|
||||||
|
for _, c := range raw {
|
||||||
|
if c.Action == "" && hasSchema[c.Table] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, c)
|
||||||
|
}
|
||||||
|
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range changes {
|
||||||
|
seen[c.Table] = true
|
||||||
}
|
}
|
||||||
est := intFromAny(preview["estimated_seconds"])
|
est := intFromAny(preview["estimated_seconds"])
|
||||||
if est == 0 {
|
if est == 0 {
|
||||||
est = 30 // PRD 兜底
|
est = 30 // PRD 兜底
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"target": target, "tables_affected": tablesAffected,
|
"target": target, "tables_affected": len(seen),
|
||||||
"changes": changes, "estimated_seconds": est,
|
"changes": changes, "estimated_seconds": est,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
|||||||
Flags: append([]common.Flag{
|
Flags: append([]common.Flag{
|
||||||
{Name: "app-id", Desc: "app id", Required: true},
|
{Name: "app-id", Desc: "app id", Required: true},
|
||||||
{Name: "table", Desc: "table name", Required: true},
|
{Name: "table", Desc: "table name", Required: true},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
|||||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||||
if rctx.Format == "pretty" {
|
if rctx.Format == "pretty" {
|
||||||
params["format"] = "ddl"
|
params["format"] = "ddl"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
|||||||
{Name: "app-id", Desc: "app id", Required: true},
|
{Name: "app-id", Desc: "app id", Required: true},
|
||||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
params := map[string]interface{}{
|
params := dbEnvParams(rctx, map[string]interface{}{
|
||||||
"env": dbEnv(rctx),
|
|
||||||
"page_size": rctx.Int("page-size"),
|
"page_size": rctx.Int("page-size"),
|
||||||
}
|
})
|
||||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||||
params["page_token"] = token
|
params["page_token"] = token
|
||||||
}
|
}
|
||||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return f, true
|
return f, true
|
||||||
|
case string:
|
||||||
|
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
|
||||||
|
s := strings.TrimSpace(v)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
case nil:
|
case nil:
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
|||||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||||
{"nil", nil, 0, false},
|
{"nil", nil, 0, false},
|
||||||
{"unsupported string", "x", 0, false},
|
{"non-numeric string", "x", 0, false},
|
||||||
|
{"numeric string", "13.5", 13.5, true},
|
||||||
|
{"numeric string int", "2", 2, true},
|
||||||
|
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||||
|
{"empty string", "", 0, false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
|||||||
return rctx.Str("environment")
|
return rctx.Str("environment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbEnvParams 把 env 并入 params:仅当显式指定了环境(非空)才带 env 键;未指定(空)时
|
||||||
|
// 省略该键,由服务端按应用多环境状态自动选分支(多环境→dev,单环境→online)。与家族对
|
||||||
|
// 空可选参数的 omit-empty 约定一致——不发空串,wire 上真正不带 env。原样返回同一个 map 便于链式。
|
||||||
|
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
|
||||||
|
if env := dbEnv(rctx); env != "" {
|
||||||
|
params["env"] = env
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||||
if rctx.Changed("env") {
|
if rctx.Changed("env") {
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
|||||||
return attendees, nil
|
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{
|
var CalendarCreate = common.Shortcut{
|
||||||
Service: "calendar",
|
Service: "calendar",
|
||||||
Command: "+create",
|
Command: "+create",
|
||||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"need_notification": true,
|
"need_notification": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = guideApprovalRoomReasonError(err, attendees)
|
||||||
// Rollback: delete the event
|
// Rollback: delete the event
|
||||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"start": startStr,
|
"start": startStr,
|
||||||
"end": endStr,
|
"end": endStr,
|
||||||
}
|
}
|
||||||
|
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||||
|
resultData["recurrence"] = recurrence
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||||
var rows []map[string]interface{}
|
var rows []map[string]interface{}
|
||||||
|
|||||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||||
|
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||||
|
type calendarEventTime struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventVChat mirrors the vchat block in the API response.
|
||||||
|
type calendarEventVChat struct {
|
||||||
|
VCType string `json:"vc_type,omitempty"`
|
||||||
|
IconType string `json:"icon_type,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
MeetingURL string `json:"meeting_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventLocation mirrors the location block in the API response.
|
||||||
|
type calendarEventLocation struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventReminder mirrors a reminder entry.
|
||||||
|
type calendarEventReminder struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventOrganizer mirrors event_organizer.
|
||||||
|
type calendarEventOrganizer struct {
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventAttachment mirrors a single attachment entry.
|
||||||
|
type calendarEventAttachment struct {
|
||||||
|
FileToken string `json:"file_token,omitempty"`
|
||||||
|
FileSize string `json:"file_size,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||||
|
type calendarEventCheckInTime struct {
|
||||||
|
TimeType string `json:"time_type,omitempty"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckIn mirrors event_check_in.
|
||||||
|
type calendarEventCheckIn struct {
|
||||||
|
EnableCheckIn bool `json:"enable_check_in"`
|
||||||
|
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||||
|
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||||
|
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEvent mirrors the event object inside the API response.
|
||||||
|
type calendarEvent struct {
|
||||||
|
EventID string `json:"event_id,omitempty"`
|
||||||
|
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||||
|
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||||
|
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||||
|
Visibility string `json:"visibility,omitempty"`
|
||||||
|
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||||
|
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||||
|
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||||
|
Location *calendarEventLocation `json:"location,omitempty"`
|
||||||
|
Color int `json:"color,omitempty"`
|
||||||
|
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||||
|
Recurrence string `json:"recurrence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
IsException bool `json:"is_exception,omitempty"`
|
||||||
|
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||||
|
CreateTime string `json:"create_time,omitempty"`
|
||||||
|
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||||
|
AppLink string `json:"app_link,omitempty"`
|
||||||
|
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||||
|
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||||
|
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||||
|
rawEvent, ok := data["event"]
|
||||||
|
if !ok || rawEvent == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rawEvent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var event calendarEvent
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCalendarEventOutput converts the typed event into the output map and
|
||||||
|
// applies the four transformation rules:
|
||||||
|
// 1. create_time -> RFC3339
|
||||||
|
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||||
|
// 3. flatten event into the top-level result
|
||||||
|
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||||
|
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||||
|
raw, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||||
|
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(startMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(endMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||||
|
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||||
|
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||||
|
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, _ := out["status"].(string); status != "cancelled" {
|
||||||
|
delete(out, "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarGet gets a single calendar event detail.
|
||||||
|
var CalendarGet = common.Shortcut{
|
||||||
|
Service: "calendar",
|
||||||
|
Command: "+get",
|
||||||
|
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"calendar:calendar.event:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||||
|
{Name: "event-id", Desc: "event ID", Required: true},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||||
|
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||||
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
if eventId == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
d := common.NewDryRunAPI()
|
||||||
|
switch calendarId {
|
||||||
|
case "":
|
||||||
|
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||||
|
calendarId = "<primary>"
|
||||||
|
case "primary":
|
||||||
|
calendarId = "<primary>"
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
return d.
|
||||||
|
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||||
|
Set("calendar_id", calendarId).
|
||||||
|
Set("event_id", eventId)
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
if calendarId == "" {
|
||||||
|
calendarId = PrimaryCalendarIDStr
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped("GET",
|
||||||
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||||
|
validate.EncodePathSegment(calendarId),
|
||||||
|
validate.EncodePathSegment(eventId)),
|
||||||
|
nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := parseCalendarEvent(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := buildCalendarEventOutput(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
summary, _ := out["summary"].(string)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "(untitled)"
|
||||||
|
}
|
||||||
|
startMap, _ := out["start_time"].(map[string]interface{})
|
||||||
|
endMap, _ := out["end_time"].(map[string]interface{})
|
||||||
|
startStr, _ := startMap["datetime"].(string)
|
||||||
|
if startStr == "" {
|
||||||
|
startStr, _ = startMap["date"].(string)
|
||||||
|
}
|
||||||
|
endStr, _ := endMap["datetime"].(string)
|
||||||
|
if endStr == "" {
|
||||||
|
endStr, _ = endMap["date"].(string)
|
||||||
|
}
|
||||||
|
eventIdOut, _ := out["event_id"].(string)
|
||||||
|
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||||
|
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||||
|
row := map[string]interface{}{
|
||||||
|
"event_id": eventIdOut,
|
||||||
|
"summary": summary,
|
||||||
|
"start": startStr,
|
||||||
|
"end": endStr,
|
||||||
|
"free_busy_status": freeBusyStatus,
|
||||||
|
"self_rsvp_status": selfRsvpStatus,
|
||||||
|
}
|
||||||
|
output.PrintTable(w, []map[string]interface{}{row})
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -673,6 +673,76 @@ 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
|
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||||
// primary error stays the add failure (classification preserved) and the Hint
|
// 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
|
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||||
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
|||||||
// Shortcuts() registration test
|
// Shortcuts() registration test
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestShortcuts_Returns9(t *testing.T) {
|
func TestShortcuts_Returns10(t *testing.T) {
|
||||||
shortcuts := Shortcuts()
|
shortcuts := Shortcuts()
|
||||||
if len(shortcuts) != 9 {
|
if len(shortcuts) != 10 {
|
||||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||||
}
|
}
|
||||||
|
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, s := range shortcuts {
|
for _, s := range shortcuts {
|
||||||
names[s.Command] = true
|
names[s.Command] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("missing shortcut %s", want)
|
t.Errorf("missing shortcut %s", want)
|
||||||
}
|
}
|
||||||
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
|||||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CalendarGet tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_001",
|
||||||
|
"summary": "Daily Sync",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742515200",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"end_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742518800",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_001",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||||
|
if strings.Contains(out, "\"event\": {") {
|
||||||
|
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||||
|
t.Errorf("expected event_id in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// status=confirmed should be dropped
|
||||||
|
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||||
|
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
// timestamp must be replaced with datetime
|
||||||
|
if strings.Contains(out, "\"timestamp\":") {
|
||||||
|
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"datetime\":") {
|
||||||
|
t.Errorf("expected datetime in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||||
|
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||||
|
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_002",
|
||||||
|
"summary": "Cancelled Meeting",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
"status": "cancelled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_002",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||||
|
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_003",
|
||||||
|
"summary": "All-day",
|
||||||
|
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||||
|
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_003",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||||
|
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||||
|
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--event-id", " ",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for empty event-id")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Param != "--event-id" {
|
||||||
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_404",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error when event field is missing")
|
||||||
|
}
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(err, &ie) {
|
||||||
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||||
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
|||||||
CalendarSuggestion,
|
CalendarSuggestion,
|
||||||
CalendarMeeting,
|
CalendarMeeting,
|
||||||
CalendarSearchEvent,
|
CalendarSearchEvent,
|
||||||
|
CalendarGet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,17 @@ const (
|
|||||||
html5BlockDataAttr = "data"
|
html5BlockDataAttr = "data"
|
||||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||||
html5BlockReferenceMaxRaw = 1024
|
html5BlockReferenceMaxRaw = 1024
|
||||||
|
|
||||||
|
whiteboardTag = "whiteboard"
|
||||||
|
whiteboardTypeAttr = "type"
|
||||||
|
whiteboardPathAttr = "path"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type html5BlockReferenceEntry struct {
|
type html5BlockReferenceEntry struct {
|
||||||
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
|
|||||||
SelfClosing bool
|
SelfClosing bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type whiteboardStartTag struct {
|
||||||
|
Attrs []html5BlockAttr
|
||||||
|
SelfClosing bool
|
||||||
|
}
|
||||||
|
|
||||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||||
body := buildCreateBody(runtime)
|
body := buildCreateBody(runtime)
|
||||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||||
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
|||||||
return docsV2WriteInput{}, err
|
return docsV2WriteInput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||||
|
if err != nil {
|
||||||
|
return docsV2WriteInput{}, err
|
||||||
|
}
|
||||||
|
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return docsV2WriteInput{}, err
|
return docsV2WriteInput{}, err
|
||||||
}
|
}
|
||||||
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
|||||||
return out, compactReferenceMap(refMap), nil
|
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 {
|
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||||
validateSegment := func(segment string) error {
|
validateSegment := func(segment string) error {
|
||||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||||
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
|||||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
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) {
|
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||||
for _, attr := range t.Attrs {
|
for _, attr := range t.Attrs {
|
||||||
if attr.Name == name {
|
if attr.Name == name {
|
||||||
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
|||||||
return "", false
|
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 {
|
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||||
_, ok := t.attr(name)
|
_, ok := t.attr(name)
|
||||||
return ok
|
return ok
|
||||||
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
|||||||
t.Attrs = attrs
|
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 {
|
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteByte('<')
|
b.WriteByte('<')
|
||||||
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
|||||||
return b.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 {
|
func escapeXMLAttr(value string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range value {
|
for _, r := range value {
|
||||||
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func escapeXMLText(value string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range value {
|
||||||
|
switch r {
|
||||||
|
case '&':
|
||||||
|
b.WriteString("&")
|
||||||
|
case '<':
|
||||||
|
b.WriteString("<")
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ package doc
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -116,6 +118,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
files := map[string]string{
|
||||||
|
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
|
||||||
|
"flow.mmd": "flowchart TD\nA --> B",
|
||||||
|
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
|
||||||
|
}
|
||||||
|
for name, content := range files {
|
||||||
|
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%s) error: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||||
|
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||||
|
"document": map[string]interface{}{
|
||||||
|
"document_id": "doxcn_new_doc",
|
||||||
|
"revision_id": float64(1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+create",
|
||||||
|
"--api-version", "v2",
|
||||||
|
"--content", strings.Join([]string{
|
||||||
|
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
|
||||||
|
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
|
||||||
|
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
|
||||||
|
}, "\n"),
|
||||||
|
"--as", "user",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := decodeRequestBody(t, stub.CapturedBody)
|
||||||
|
got := body["content"].(string)
|
||||||
|
for _, want := range []string{
|
||||||
|
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
|
||||||
|
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
|
||||||
|
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("content missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `path="@`) {
|
||||||
|
t.Fatalf("content still contains whiteboard path attr: %s", got)
|
||||||
|
}
|
||||||
|
if _, ok := body["reference_map"]; ok {
|
||||||
|
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||||
for _, flag := range flags {
|
for _, flag := range flags {
|
||||||
if flag.Name == name {
|
if flag.Name == name {
|
||||||
@@ -407,6 +464,119 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||||
|
|
||||||
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+create",
|
||||||
|
"--api-version", "v2",
|
||||||
|
"--content", strings.Join([]string{
|
||||||
|
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
|
||||||
|
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
|
||||||
|
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
|
||||||
|
}, "\n"),
|
||||||
|
"--as", "user",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected aggregated whiteboard path error")
|
||||||
|
}
|
||||||
|
assertWhiteboardFileInputValidation(t, err, []string{
|
||||||
|
"missing.svg",
|
||||||
|
"missing.mmd",
|
||||||
|
"missing.puml",
|
||||||
|
}, []string{
|
||||||
|
`whiteboard svg path "missing.svg" cannot be read`,
|
||||||
|
`whiteboard mermaid path "missing.mmd" cannot be read`,
|
||||||
|
`whiteboard plantuml path "missing.puml" cannot be read`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cmdutil.TestChdir(t, dir)
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||||
|
|
||||||
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+create",
|
||||||
|
"--api-version", "v2",
|
||||||
|
"--doc-format", "markdown",
|
||||||
|
"--content", strings.Join([]string{
|
||||||
|
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
|
||||||
|
"```",
|
||||||
|
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
|
||||||
|
"```",
|
||||||
|
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
|
||||||
|
}, "\n"),
|
||||||
|
"--as", "user",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected aggregated whiteboard path error")
|
||||||
|
}
|
||||||
|
assertWhiteboardFileInputValidation(t, err, []string{
|
||||||
|
"before.svg",
|
||||||
|
"after.puml",
|
||||||
|
}, []string{
|
||||||
|
`whiteboard svg path "before.svg" cannot be read`,
|
||||||
|
`whiteboard plantuml path "after.puml" cannot be read`,
|
||||||
|
})
|
||||||
|
if strings.Contains(err.Error(), "inside.svg") {
|
||||||
|
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
|
||||||
|
t.Helper()
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "whiteboard" {
|
||||||
|
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
|
||||||
|
}
|
||||||
|
if validationErr.Cause == nil {
|
||||||
|
t.Fatal("expected aggregated error to preserve cause")
|
||||||
|
}
|
||||||
|
var childValidationErr *errs.ValidationError
|
||||||
|
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
|
||||||
|
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotParams := make(map[string]string, len(validationErr.Params))
|
||||||
|
for _, param := range validationErr.Params {
|
||||||
|
gotParams[param.Name] = param.Reason
|
||||||
|
}
|
||||||
|
if len(gotParams) != len(wantParams) {
|
||||||
|
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
|
||||||
|
}
|
||||||
|
for _, param := range wantParams {
|
||||||
|
reason, ok := gotParams[param]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
|
||||||
|
}
|
||||||
|
if reason == "" {
|
||||||
|
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range wantMessages {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error missing %q:\n%v", want, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validationErr.Cause.Error(), want) {
|
||||||
|
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cmdutil.TestChdir(t, dir)
|
cmdutil.TestChdir(t, dir)
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
|
|||||||
|
|
||||||
var downloaded, skipped, failed, deletedLocal int
|
var downloaded, skipped, failed, deletedLocal int
|
||||||
downloadFailed := 0
|
downloadFailed := 0
|
||||||
|
aborted := false
|
||||||
items := make([]drivePullItem, 0)
|
items := make([]drivePullItem, 0)
|
||||||
|
|
||||||
// Deterministic iteration order for output stability.
|
// Deterministic iteration order for output stability.
|
||||||
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
|
|||||||
sort.Strings(downloadablePaths)
|
sort.Strings(downloadablePaths)
|
||||||
|
|
||||||
for _, rel := range downloadablePaths {
|
for _, rel := range downloadablePaths {
|
||||||
if drivePullHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
targetFile := remoteFiles[rel]
|
targetFile := remoteFiles[rel]
|
||||||
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
downloadFailed++
|
downloadFailed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"deleted_local": deletedLocal,
|
"deleted_local": deletedLocal,
|
||||||
"aborted": drivePullHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
|||||||
return item, decision.Terminal
|
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
|
// drivePullDownload streams one Drive file into the local mirror target and
|
||||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
// 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 {
|
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type drivePushItem struct {
|
|||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
ErrorClass string `json:"error_class,omitempty"`
|
ErrorClass string `json:"error_class,omitempty"`
|
||||||
Code int `json:"code,omitempty"`
|
Code int `json:"code,omitempty"`
|
||||||
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
|
|||||||
Subtype string
|
Subtype string
|
||||||
Retryable bool
|
Retryable bool
|
||||||
Terminal bool
|
Terminal bool
|
||||||
|
Hint string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||||
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
|
|||||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||||
// outcome the review flagged.
|
// outcome the review flagged.
|
||||||
uploadFailed := false
|
uploadFailed := false
|
||||||
|
aborted := false
|
||||||
|
|
||||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||||
// listing (so we don't recreate folders that already exist) and
|
// listing (so we don't recreate folders that already exist) and
|
||||||
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
|
|||||||
|
|
||||||
for _, rel := range localPaths {
|
for _, rel := range localPaths {
|
||||||
localFile := localFiles[rel]
|
localFile := localFiles[rel]
|
||||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
if uploadFailed && aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
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)
|
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||||
abortDelete = true
|
abortDelete = true
|
||||||
break
|
break
|
||||||
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"deleted_remote": deletedRemote,
|
"deleted_remote": deletedRemote,
|
||||||
"aborted": drivePushHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
|||||||
Action: action,
|
Action: action,
|
||||||
SizeBytes: sizeBytes,
|
SizeBytes: sizeBytes,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
|
Hint: decision.Hint,
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
ErrorClass: decision.Class,
|
ErrorClass: decision.Class,
|
||||||
Code: decision.Code,
|
Code: decision.Code,
|
||||||
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|||||||
decision.Class = "file_size_limit"
|
decision.Class = "file_size_limit"
|
||||||
case problem.Code == 1062009:
|
case problem.Code == 1062009:
|
||||||
decision.Class = "upload_size_mismatch"
|
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:
|
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||||
decision.Class = "remote_not_found"
|
decision.Class = "remote_not_found"
|
||||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||||
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|||||||
return decision
|
return decision
|
||||||
}
|
}
|
||||||
|
|
||||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
func drivePushIsAlreadyDeleted(err error) bool {
|
||||||
for _, item := range items {
|
problem, ok := errs.ProblemOf(err)
|
||||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
return ok && problem.Code == 1061007
|
||||||
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) {
|
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||||
|
|||||||
@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
withDriveWorkingDir(t, tmpDir)
|
||||||
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "folder_token=folder_root",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"files": []interface{}{
|
||||||
|
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||||
|
},
|
||||||
|
"has_more": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE",
|
||||||
|
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 1061007,
|
||||||
|
"msg": "file has been delete.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRunDrive(t, DrivePush, []string{
|
||||||
|
"+push",
|
||||||
|
"--local-dir", "local",
|
||||||
|
"--folder-token", "folder_root",
|
||||||
|
"--delete-remote",
|
||||||
|
"--yes",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||||
|
if got := summary["failed"]; got != float64(0) {
|
||||||
|
t.Fatalf("summary.failed = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := summary["deleted_remote"]; got != float64(0) {
|
||||||
|
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||||
|
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
withDriveWorkingDir(t, tmpDir)
|
||||||
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile a: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "folder_token=folder_root",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/drive/v1/files/upload_all",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 1061044,
|
||||||
|
"msg": "parent node not exist.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRunDrive(t, DrivePush, []string{
|
||||||
|
"+push",
|
||||||
|
"--local-dir", "local",
|
||||||
|
"--folder-token", "folder_root",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||||
|
}
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||||
|
if got := summary["failed"]; got != float64(1) {
|
||||||
|
t.Fatalf("summary.failed = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got := summary["aborted"]; got != true {
|
||||||
|
t.Fatalf("summary.aborted = %v, want true", got)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||||
|
t.Fatalf("unexpected failed item: %#v", item)
|
||||||
|
}
|
||||||
|
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||||
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||||
|
}
|
||||||
|
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||||
|
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item["rel_path"] == "b.txt" {
|
||||||
|
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// --- Phase 2: Execute sync operations ---
|
// --- Phase 2: Execute sync operations ---
|
||||||
var pulled, pushed, skipped, failed int
|
var pulled, pushed, skipped, failed int
|
||||||
|
aborted := false
|
||||||
items := make([]driveSyncItem, 0)
|
items := make([]driveSyncItem, 0)
|
||||||
|
|
||||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||||
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
|
|||||||
// Mirror local directory structure first (same as +push), so
|
// Mirror local directory structure first (same as +push), so
|
||||||
// empty local directories are not silently dropped.
|
// empty local directories are not silently dropped.
|
||||||
for _, relDir := range localDirs {
|
for _, relDir := range localDirs {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||||
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2a. Pull new_remote files.
|
// 2a. Pull new_remote files.
|
||||||
for _, entry := range newRemote {
|
for _, entry := range newRemote {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||||
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2b. Push new_local files.
|
// 2b. Push new_local files.
|
||||||
for _, entry := range newLocal {
|
for _, entry := range newLocal {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||||
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
|
|||||||
parentRel := drivePushParentRel(entry.RelPath)
|
parentRel := drivePushParentRel(entry.RelPath)
|
||||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||||
if ensureErr != nil {
|
if ensureErr != nil {
|
||||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||||
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2c. Resolve modified files by --on-conflict strategy.
|
// 2c. Resolve modified files by --on-conflict strategy.
|
||||||
for _, entry := range modified {
|
for _, entry := range modified {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
remoteFile := remoteFiles[entry.RelPath]
|
remoteFile := remoteFiles[entry.RelPath]
|
||||||
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
|
|||||||
}
|
}
|
||||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||||
if parentErr != nil {
|
if parentErr != nil {
|
||||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||||
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
|
|||||||
"pushed": pushed,
|
"pushed": pushed,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"aborted": driveSyncHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
|||||||
return item, decision.Terminal
|
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
|
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||||
// for a single file. Returns the strategy string, or empty string if the
|
// for a single file. Returns the strategy string, or empty string if the
|
||||||
// user chose to skip.
|
// user chose to skip.
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
|||||||
// original message as read after a reply/reply-all/forward operation.
|
// original message as read after a reply/reply-all/forward operation.
|
||||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut,
|
fmt.Fprintf(runtime.IO().ErrOut,
|
||||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||||
|
|||||||
@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
|||||||
// TestHintMarkAsRead verifies hint mark as read.
|
// TestHintMarkAsRead verifies hint mark as read.
|
||||||
func TestHintMarkAsRead(t *testing.T) {
|
func TestHintMarkAsRead(t *testing.T) {
|
||||||
rt, _, stderr := newOutputRuntime(t)
|
rt, _, stderr := newOutputRuntime(t)
|
||||||
// Inject ANSI escape + message ID to verify sanitization
|
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
|
||||||
out := stderr.String()
|
out := stderr.String()
|
||||||
if strings.Contains(out, "\x1b[") {
|
if strings.Contains(out, "\x1b[") {
|
||||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "msg-123") {
|
if strings.Contains(out, "\nnext") {
|
||||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||||
|
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||||
|
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
482
shortcuts/mail/mail_message_manage_test.go
Normal file
482
shortcuts/mail/mail_message_manage_test.go
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/auth"
|
||||||
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func messageManageID(suffix string) string {
|
||||||
|
return "msg_abcdefghijklmnop_" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
return stub
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
success, ok := data["success_message_ids"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||||
|
}
|
||||||
|
failed, ok := data["failed_message_ids"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||||
|
}
|
||||||
|
return success, failed
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error for %s, got nil", param)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||||
|
}
|
||||||
|
if validationErr.Param != param {
|
||||||
|
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||||
|
}
|
||||||
|
return validationErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected failed precondition error, got nil")
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed Problem, got %T", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||||
|
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||||
|
}
|
||||||
|
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||||
|
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := [][]string{
|
||||||
|
{""},
|
||||||
|
{" id_with_leading_space_12345"},
|
||||||
|
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||||
|
{"1234567890123456"},
|
||||||
|
{"short"},
|
||||||
|
{"msg_abcdefghijklmnop!"},
|
||||||
|
{"msg_abcdefghijklmnop\t"},
|
||||||
|
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||||
|
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, err := normalizeMessageManageIDs(tc)
|
||||||
|
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_Metadata(t *testing.T) {
|
||||||
|
if MailMessageModify.Command != "+message-modify" {
|
||||||
|
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||||
|
}
|
||||||
|
if MailMessageModify.Risk != "write" {
|
||||||
|
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||||
|
}
|
||||||
|
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||||
|
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||||
|
}
|
||||||
|
requiredScopes := map[string]bool{
|
||||||
|
"mail:user_mailbox.message:modify": true,
|
||||||
|
}
|
||||||
|
for _, scope := range MailMessageModify.Scopes {
|
||||||
|
delete(requiredScopes, scope)
|
||||||
|
}
|
||||||
|
if len(requiredScopes) != 0 {
|
||||||
|
t.Errorf("Scopes missing %v", requiredScopes)
|
||||||
|
}
|
||||||
|
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||||
|
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||||
|
}
|
||||||
|
flags := map[string]common.Flag{}
|
||||||
|
for _, fl := range MailMessageModify.Flags {
|
||||||
|
flags[fl.Name] = fl
|
||||||
|
}
|
||||||
|
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||||
|
if _, ok := flags[name]; !ok {
|
||||||
|
t.Fatalf("missing --%s flag", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||||
|
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageTrash_Metadata(t *testing.T) {
|
||||||
|
if MailMessageTrash.Command != "+message-trash" {
|
||||||
|
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||||
|
}
|
||||||
|
if MailMessageTrash.Risk != "high-risk-write" {
|
||||||
|
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||||
|
}
|
||||||
|
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||||
|
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||||
|
}
|
||||||
|
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||||
|
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||||
|
if token == nil {
|
||||||
|
t.Fatal("expected test token")
|
||||||
|
}
|
||||||
|
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||||
|
if err := auth.SetStoredToken(token); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := messageManageID("1")
|
||||||
|
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--remove-label-ids", "UNREAD",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
removeLabels := body["remove_label_ids"].([]interface{})
|
||||||
|
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||||
|
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--remove-label-ids", "read_receipt_request",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
removeLabels := body["remove_label_ids"].([]interface{})
|
||||||
|
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||||
|
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||||
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||||
|
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-label-ids", "unread,customA",
|
||||||
|
"--remove-label-ids", "FLAGGED",
|
||||||
|
"--add-folder", "folderA",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
if got := body["add_folder"]; got != "folderA" {
|
||||||
|
t.Errorf("add_folder = %v, want folderA", got)
|
||||||
|
}
|
||||||
|
addLabels := body["add_label_ids"].([]interface{})
|
||||||
|
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||||
|
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||||
|
}
|
||||||
|
removeLabels := body["remove_label_ids"].([]interface{})
|
||||||
|
if removeLabels[0] != "FLAGGED" {
|
||||||
|
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||||
|
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-label-ids", "unread",
|
||||||
|
"--remove-label-ids", "UNREAD",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||||
|
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||||
|
t.Fatalf("error = %v, want label intersection validation", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-folder", "trash",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||||
|
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||||
|
t.Fatalf("error = %v, want TRASH validation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||||
|
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
ids := make([]string, 41)
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||||
|
}
|
||||||
|
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||||
|
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", strings.Join(ids, ","),
|
||||||
|
"--add-folder", "archive",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||||
|
}
|
||||||
|
messageIDs := body["message_ids"].([]interface{})
|
||||||
|
want := []int{20, 20, 1}[idx]
|
||||||
|
if len(messageIDs) != want {
|
||||||
|
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||||
|
}
|
||||||
|
if body["add_folder"] != "ARCHIVED" {
|
||||||
|
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 21 || len(failed) != 20 {
|
||||||
|
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-folder", "archive",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageFailedPrecondition(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id1 + "," + id2,
|
||||||
|
"--add-label-ids", "customA",
|
||||||
|
"--add-folder", "folderA",
|
||||||
|
"--dry-run",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run failed: %v", err)
|
||||||
|
}
|
||||||
|
out := stdout.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`/user_mailboxes/me/messages/batch_modify`,
|
||||||
|
`validation_api_plan`,
|
||||||
|
`/user_mailboxes/me/labels/customA`,
|
||||||
|
`/user_mailboxes/me/folders/folderA`,
|
||||||
|
`will_validate`,
|
||||||
|
`batch_size`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||||
|
"+message-trash",
|
||||||
|
"--message-ids", id1 + "," + id2,
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected confirmation error, got nil")
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||||
|
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||||
|
"+message-trash",
|
||||||
|
"--message-ids", id1 + "," + id2,
|
||||||
|
"--yes",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err with --yes: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||||
|
t.Fatalf("message_ids len = %d, want 2", got)
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 2 || len(failed) != 0 {
|
||||||
|
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||||
|
"+message-trash",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--yes",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageFailedPrecondition(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
shortcut common.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "trash newline in repeated flag",
|
||||||
|
shortcut: MailMessageTrash,
|
||||||
|
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trash tab in csv flag",
|
||||||
|
shortcut: MailMessageTrash,
|
||||||
|
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modify space in repeated flag",
|
||||||
|
shortcut: MailMessageModify,
|
||||||
|
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modify space in csv flag",
|
||||||
|
shortcut: MailMessageModify,
|
||||||
|
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
|
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||||
|
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
141
shortcuts/mail/mail_message_modify.go
Normal file
141
shortcuts/mail/mail_message_modify.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type messageModifyInput struct {
|
||||||
|
MessageIDs []string
|
||||||
|
AddLabelIDs []string
|
||||||
|
RemoveLabelIDs []string
|
||||||
|
AddFolder string
|
||||||
|
CustomLabelIDs []string
|
||||||
|
CustomFolderID string
|
||||||
|
ValidationAPIPlans []validationAPIPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||||
|
// state labels, or a folder move to existing messages in batches of 20.
|
||||||
|
var MailMessageModify = common.Shortcut{
|
||||||
|
Service: "mail",
|
||||||
|
Command: "+message-modify",
|
||||||
|
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||||
|
Risk: "write",
|
||||||
|
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||||
|
ConditionalScopes: []string{
|
||||||
|
"mail:user_mailbox.folder:read",
|
||||||
|
},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||||
|
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||||
|
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||||
|
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||||
|
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||||
|
},
|
||||||
|
Validate: validateMessageModify,
|
||||||
|
DryRun: dryRunMessageModify,
|
||||||
|
Execute: executeMessageModify,
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
_, err := buildMessageModifyInput(rt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
input, _ := buildMessageModifyInput(rt)
|
||||||
|
api := common.NewDryRunAPI().
|
||||||
|
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||||
|
Set("batch_size", mailMessageManageBatchSize).
|
||||||
|
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||||
|
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||||
|
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||||
|
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||||
|
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
input, err := buildMessageModifyInput(rt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||||
|
emitMessageManageSummary(rt, messageManageSummary{
|
||||||
|
SuccessMessageIDs: input.MessageIDs,
|
||||||
|
FailedMessageIDs: []messageManageFailure{},
|
||||||
|
}, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||||
|
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||||
|
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||||
|
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||||
|
if err != nil {
|
||||||
|
for _, id := range batch {
|
||||||
|
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||||
|
}
|
||||||
|
emitMessageManageSummary(rt, summary, false)
|
||||||
|
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||||
|
return mailFailedPreconditionError("all message modify batches failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||||
|
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||||
|
customFolderID := ""
|
||||||
|
if customFolder {
|
||||||
|
customFolderID = folder
|
||||||
|
}
|
||||||
|
return messageModifyInput{
|
||||||
|
MessageIDs: messageIDs,
|
||||||
|
AddLabelIDs: addLabels,
|
||||||
|
RemoveLabelIDs: removeLabels,
|
||||||
|
AddFolder: folder,
|
||||||
|
CustomLabelIDs: customLabels,
|
||||||
|
CustomFolderID: customFolderID,
|
||||||
|
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
75
shortcuts/mail/mail_message_trash.go
Normal file
75
shortcuts/mail/mail_message_trash.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||||
|
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||||
|
// runner requires --yes before Execute.
|
||||||
|
var MailMessageTrash = common.Shortcut{
|
||||||
|
Service: "mail",
|
||||||
|
Command: "+message-trash",
|
||||||
|
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||||
|
Risk: "high-risk-write",
|
||||||
|
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||||
|
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||||
|
},
|
||||||
|
Validate: validateMessageTrash,
|
||||||
|
DryRun: dryRunMessageTrash,
|
||||||
|
Execute: executeMessageTrash,
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
api := common.NewDryRunAPI().
|
||||||
|
Desc("Soft-delete messages sequentially in batches of 20").
|
||||||
|
Set("batch_size", mailMessageManageBatchSize).
|
||||||
|
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||||
|
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||||
|
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||||
|
Body(map[string]interface{}{"message_ids": batch})
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||||
|
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||||
|
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||||
|
map[string]interface{}{"message_ids": batch})
|
||||||
|
if err != nil {
|
||||||
|
for _, id := range batch {
|
||||||
|
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||||
|
}
|
||||||
|
emitMessageManageSummary(rt, summary, false)
|
||||||
|
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||||
|
return mailFailedPreconditionError("all message trash batches failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
|||||||
RefreshToken: "test-refresh-token",
|
RefreshToken: "test-refresh-token",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||||
}
|
}
|
||||||
if err := auth.SetStoredToken(token); err != nil {
|
if err := auth.SetStoredToken(token); err != nil {
|
||||||
|
|||||||
283
shortcuts/mail/message_manage_helpers.go
Normal file
283
shortcuts/mail/message_manage_helpers.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mailMessageManageBatchSize = 20
|
||||||
|
|
||||||
|
var messageManageSystemLabels = map[string]string{
|
||||||
|
"UNREAD": "UNREAD",
|
||||||
|
"IMPORTANT": "IMPORTANT",
|
||||||
|
"OTHER": "OTHER",
|
||||||
|
"FLAGGED": "FLAGGED",
|
||||||
|
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageManageSystemFolders = map[string]string{
|
||||||
|
"INBOX": "INBOX",
|
||||||
|
"SENT": "SENT",
|
||||||
|
"SPAM": "SPAM",
|
||||||
|
"ARCHIVE": "ARCHIVED",
|
||||||
|
"ARCHIVED": "ARCHIVED",
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageManageSummary struct {
|
||||||
|
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||||
|
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageManageFailure struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationAPIPlan struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
WillValidate bool `json:"will_validate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||||
|
}
|
||||||
|
parts, err := splitMessageManageIDTokens(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(part)
|
||||||
|
if id == "" {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||||
|
}
|
||||||
|
if id != part {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||||
|
}
|
||||||
|
if err := validateMessageManageID(id, i); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||||
|
parts := make([]string, 0, len(raw))
|
||||||
|
for i, token := range raw {
|
||||||
|
for _, r := range token {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, strings.Split(token, ",")...)
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageManageID(id string, index int) error {
|
||||||
|
if len(id) < 16 {
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||||
|
}
|
||||||
|
if strings.Trim(id, "0123456789") == "" {
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||||
|
}
|
||||||
|
for _, r := range id {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||||
|
}
|
||||||
|
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
case '+', '/', '=', '_', '-':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||||
|
labels := make([]string, 0, len(raw))
|
||||||
|
custom := make([]string, 0, len(raw))
|
||||||
|
seen := make(map[string]struct{}, len(raw))
|
||||||
|
for i, part := range raw {
|
||||||
|
id := strings.TrimSpace(part)
|
||||||
|
if id == "" {
|
||||||
|
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||||
|
}
|
||||||
|
if id != part {
|
||||||
|
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||||
|
}
|
||||||
|
normalized := id
|
||||||
|
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||||
|
normalized = system
|
||||||
|
} else {
|
||||||
|
custom = append(custom, id)
|
||||||
|
}
|
||||||
|
if _, ok := seen[normalized]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
labels = append(labels, normalized)
|
||||||
|
}
|
||||||
|
if len(labels) > 20 {
|
||||||
|
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||||
|
}
|
||||||
|
return labels, custom, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLabelIntersection(add, remove []string) error {
|
||||||
|
removeSet := make(map[string]struct{}, len(remove))
|
||||||
|
for _, id := range remove {
|
||||||
|
removeSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, id := range add {
|
||||||
|
if _, ok := removeSet[id]; ok {
|
||||||
|
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
folder := strings.TrimSpace(raw)
|
||||||
|
if folder == "" {
|
||||||
|
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||||
|
}
|
||||||
|
if folder != raw {
|
||||||
|
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(folder, "TRASH") {
|
||||||
|
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||||
|
}
|
||||||
|
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||||
|
return system, false, nil
|
||||||
|
}
|
||||||
|
return folder, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkMessageManageIDs(ids []string) [][]string {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||||
|
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||||
|
end := start + mailMessageManageBatchSize
|
||||||
|
if end > len(ids) {
|
||||||
|
end = len(ids)
|
||||||
|
}
|
||||||
|
chunks = append(chunks, ids[start:end])
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validateLabelReadScope(rt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||||
|
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validateFolderReadScope(rt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||||
|
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||||
|
body := map[string]interface{}{"message_ids": ids}
|
||||||
|
if len(addLabels) > 0 {
|
||||||
|
body["add_label_ids"] = addLabels
|
||||||
|
}
|
||||||
|
if len(removeLabels) > 0 {
|
||||||
|
body["remove_label_ids"] = removeLabels
|
||||||
|
}
|
||||||
|
if addFolder != "" {
|
||||||
|
body["add_folder"] = addFolder
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||||
|
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||||
|
seenLabels := map[string]struct{}{}
|
||||||
|
for _, id := range customLabels {
|
||||||
|
if _, ok := seenLabels[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenLabels[id] = struct{}{}
|
||||||
|
plans = append(plans, validationAPIPlan{
|
||||||
|
Method: "GET",
|
||||||
|
Path: mailboxPath(mailboxID, "labels", id),
|
||||||
|
WillValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if customFolder != "" {
|
||||||
|
plans = append(plans, validationAPIPlan{
|
||||||
|
Method: "GET",
|
||||||
|
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||||
|
WillValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||||
|
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||||
|
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||||
|
if noAPICalls {
|
||||||
|
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||||
|
}
|
||||||
|
for _, item := range summary.FailedMessageIDs {
|
||||||
|
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
|
|||||||
return []common.Shortcut{
|
return []common.Shortcut{
|
||||||
MailMessage,
|
MailMessage,
|
||||||
MailMessages,
|
MailMessages,
|
||||||
|
MailMessageModify,
|
||||||
|
MailMessageTrash,
|
||||||
MailThread,
|
MailThread,
|
||||||
MailTriage,
|
MailTriage,
|
||||||
MailWatch,
|
MailWatch,
|
||||||
|
|||||||
@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
|
|||||||
case 90003087:
|
case 90003087:
|
||||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||||
case 1061003, 1061044:
|
case 1061003, 1061044:
|
||||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||||
case 1061004, 1062501:
|
case 1061004, 1062501:
|
||||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
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
|
return err
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
"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.",
|
"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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return err
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
}
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
return validateMarkdownSpec(runtime, spec, true)
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}, true)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
spec := markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return common.NewDryRunAPI().Set("error", err.Error())
|
||||||
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)
|
fileSize, err := markdownSourceSize(runtime, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
return dry
|
return dry
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
spec := markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return err
|
||||||
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)
|
fileSize, err := markdownSourceSize(runtime, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||||
|
spec := markdownUploadSpec{
|
||||||
|
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||||
|
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||||
|
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||||
|
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||||
|
FileSet: runtime.Changed("file"),
|
||||||
|
Content: runtime.Str("content"),
|
||||||
|
ContentSet: runtime.Changed("content"),
|
||||||
|
}
|
||||||
|
return normalizeMarkdownCreateTargetSpec(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||||
|
if spec.FolderToken != "" {
|
||||||
|
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||||
|
if err != nil {
|
||||||
|
return markdownUploadSpec{}, err
|
||||||
|
}
|
||||||
|
spec.FolderToken = token
|
||||||
|
}
|
||||||
|
if spec.WikiToken != "" {
|
||||||
|
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||||
|
if err != nil {
|
||||||
|
return markdownUploadSpec{}, err
|
||||||
|
}
|
||||||
|
spec.WikiToken = token
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if strings.Contains(token, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(token)
|
||||||
|
if !ok {
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||||
|
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||||
|
}
|
||||||
|
if ref.Type != "folder" {
|
||||||
|
return "", markdownValidationParamError("--folder-token",
|
||||||
|
"--folder-token must identify a Drive folder; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ref.Token, nil
|
||||||
|
}
|
||||||
|
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch markdownKnownResourceTokenKind(token) {
|
||||||
|
case "wiki":
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||||
|
WithHint("Pass it with --wiki-token instead.")
|
||||||
|
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if strings.Contains(token, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(token)
|
||||||
|
if !ok {
|
||||||
|
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||||
|
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||||
|
}
|
||||||
|
if ref.Type != "wiki" {
|
||||||
|
return "", markdownValidationParamError("--wiki-token",
|
||||||
|
"--wiki-token must identify a wiki node; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ref.Token, nil
|
||||||
|
}
|
||||||
|
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||||
|
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||||
|
if strings.ContainsAny(token, "/?#") {
|
||||||
|
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||||
|
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||||
|
if err := validate.ResourceName(token, flagName); err != nil {
|
||||||
|
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownKnownResourceTokenKind(token string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(token))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "wik"):
|
||||||
|
return "wiki"
|
||||||
|
case strings.HasPrefix(lower, "docx"):
|
||||||
|
return "docx"
|
||||||
|
case strings.HasPrefix(lower, "doc"):
|
||||||
|
return "doc"
|
||||||
|
case strings.HasPrefix(lower, "sht"):
|
||||||
|
return "sheet"
|
||||||
|
case strings.HasPrefix(lower, "bas"):
|
||||||
|
return "bitable"
|
||||||
|
case strings.HasPrefix(lower, "mn"):
|
||||||
|
return "mindnote"
|
||||||
|
case strings.HasPrefix(lower, "sld"):
|
||||||
|
return "slides"
|
||||||
|
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||||
|
return "file"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||||
|
"--dry-run",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||||
|
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||||
|
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||||
|
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected folder-token URL type error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||||
|
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||||
|
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() (string, error)
|
||||||
|
wantMsg string
|
||||||
|
wantHint string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wiki token passed as folder token",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||||
|
wantMsg: "--folder-token looks like a wiki node token",
|
||||||
|
wantHint: "--wiki-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "folder token path fragment",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||||
|
wantMsg: "--folder-token must be a raw token",
|
||||||
|
wantHint: "full Lark URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doc token passed as wiki token",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||||
|
wantMsg: "--wiki-token must be a wiki node token",
|
||||||
|
wantHint: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki token query fragment",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||||
|
wantMsg: "--wiki-token must be a raw token",
|
||||||
|
wantHint: "path/query/fragment",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := tt.run()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||||
|
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||||
|
}
|
||||||
|
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||||
|
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if folderToken != "folder_token_raw" {
|
||||||
|
t.Fatalf("folder token = %q", folderToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if wikiToken != "wik_placeholder_raw" {
|
||||||
|
t.Fatalf("wiki token = %q", wikiToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||||
|
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||||
|
p, ok := errs.ProblemOf(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||||
|
t.Fatalf("quota hint = %q", p.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||||
|
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||||
|
p, ok = errs.ProblemOf(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||||
|
}
|
||||||
|
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||||
|
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import (
|
|||||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||||
|
|
||||||
// Error codes from the minutes API.
|
// Error codes from the minutes API.
|
||||||
const minutesDetailNoReadPermissionCode = 2091005
|
const (
|
||||||
|
minutesDetailProcessingCode = 2091003
|
||||||
|
minutesDetailNoReadPermissionCode = 2091005
|
||||||
|
minutesDetailWaitTimeoutDefault = 300
|
||||||
|
minutesDetailWaitIntervalDefault = 15
|
||||||
|
)
|
||||||
|
|
||||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||||
|
|
||||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
|||||||
// minuteDetailItem represents a single minute detail result.
|
// minuteDetailItem represents a single minute detail result.
|
||||||
type minuteDetailItem struct {
|
type minuteDetailItem struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
MinuteToken string `json:"minute_token"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
Error string `json:"error,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.
|
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
waitReady := runtime.Bool("wait-ready")
|
||||||
|
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||||
|
|
||||||
|
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||||
|
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||||
} else {
|
} else {
|
||||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
needKeyword := runtime.Bool("keyword")
|
needKeyword := runtime.Bool("keyword")
|
||||||
|
|
||||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||||
|
} else {
|
||||||
|
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
artifacts := make(map[string]any)
|
artifacts := make(map[string]any)
|
||||||
if needSummary {
|
if needSummary {
|
||||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
return result
|
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.
|
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
|||||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
{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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
|||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||||
if r.Error != "" {
|
if r.Error != "" {
|
||||||
row["status"] = "FAIL"
|
if r.Status == "processing" {
|
||||||
|
row["status"] = "PROCESSING"
|
||||||
|
} else {
|
||||||
|
row["status"] = "FAIL"
|
||||||
|
}
|
||||||
row["error"] = r.Error
|
row["error"] = r.Error
|
||||||
|
if r.NextCommand != "" {
|
||||||
|
row["next_command"] = r.NextCommand
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
row["status"] = "OK"
|
row["status"] = "OK"
|
||||||
row["title"] = r.Title
|
row["title"] = r.Title
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detailProcessingStub(path string) *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: path,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 2091003,
|
||||||
|
"msg": "minute is processing",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesDetail.Mount(parent, f)
|
||||||
|
parent.SetOut(stdout)
|
||||||
|
parent.SetArgs([]string{"+detail", "--help"})
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
if err := parent.Execute(); err != nil {
|
||||||
|
t.Fatalf("help failed: %v", err)
|
||||||
|
}
|
||||||
|
help := stdout.String()
|
||||||
|
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||||
|
if strings.Contains(help, hidden) {
|
||||||
|
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Execute tests with mocked HTTP
|
// Execute tests with mocked HTTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["retryable"] != true {
|
||||||
|
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||||
|
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["title"] != "Ready Metadata" {
|
||||||
|
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||||
|
}
|
||||||
|
if _, ok := m["artifacts"]; ok {
|
||||||
|
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||||
|
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
arts, _ := m["artifacts"].(map[string]any)
|
||||||
|
if arts == nil {
|
||||||
|
t.Fatal("expected artifacts")
|
||||||
|
}
|
||||||
|
if arts["summary"] != "Test summary content" {
|
||||||
|
t.Fatalf("summary = %v", arts["summary"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||||
|
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
var callCount int
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||||
|
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||||
|
Reusable: true,
|
||||||
|
OnMatch: func(req *http.Request) { callCount++ },
|
||||||
|
})
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pure function tests
|
// Pure function tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||||
|
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||||
|
if timeout != 9 || interval != 3 {
|
||||||
|
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||||
|
}
|
||||||
|
data, _ := resp["data"].(map[string]any)
|
||||||
|
minutes, _ := data["minutes"].([]any)
|
||||||
|
if len(minutes) != 1 {
|
||||||
|
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||||
|
}
|
||||||
|
m, _ := minutes[0].(map[string]any)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||||
func chdirForDetailTest(t *testing.T) string {
|
func chdirForDetailTest(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
|||||||
outData := map[string]interface{}{
|
outData := map[string]interface{}{
|
||||||
"minute_url": minuteURL,
|
"minute_url": minuteURL,
|
||||||
}
|
}
|
||||||
|
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||||
|
outData["minute_token"] = minuteToken
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(outData, nil, nil)
|
runtime.OutFormat(outData, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractUploadedMinuteToken(minuteURL string) string {
|
||||||
|
u, err := url.Parse(minuteURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "minutes" && i+1 < len(parts) {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
|||||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
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"])
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package vc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,9 @@ const (
|
|||||||
minVCMeetingEventsPageSize = 20
|
minVCMeetingEventsPageSize = 20
|
||||||
maxVCMeetingEventsPageSize = 100
|
maxVCMeetingEventsPageSize = 100
|
||||||
maxVCMeetingEventsPages = 200
|
maxVCMeetingEventsPages = 200
|
||||||
|
leaveReasonUserLeft = 1
|
||||||
|
leaveReasonMeetingEnded = 2
|
||||||
|
leaveReasonKicked = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||||
@@ -41,11 +46,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
|
|||||||
return ts, nil
|
return ts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VCMeetingEvents lists bot meeting events for a meeting.
|
// VCMeetingEvents lists meeting events for a meeting.
|
||||||
var VCMeetingEvents = common.Shortcut{
|
var VCMeetingEvents = common.Shortcut{
|
||||||
Service: "vc",
|
Service: "vc",
|
||||||
Command: "+meeting-events",
|
Command: "+meeting-events",
|
||||||
Description: "List bot meeting events by meeting ID",
|
Description: "List meeting events by meeting ID",
|
||||||
Risk: "read",
|
Risk: "read",
|
||||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
@@ -99,20 +104,28 @@ var VCMeetingEvents = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
events = compactMeetingEvents(events)
|
events = compactMeetingEvents(events)
|
||||||
outData := map[string]interface{}{
|
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
|
||||||
"events": events,
|
outData := buildMeetingEventsOutput(data, events, identity, identityWarning)
|
||||||
"has_more": data["has_more"],
|
metadata := map[string]interface{}{
|
||||||
"page_token": data["page_token"],
|
"row_type": "metadata",
|
||||||
|
"meeting": outData.Meeting,
|
||||||
|
"identity": outData.Identity,
|
||||||
|
"has_more": outData.HasMore,
|
||||||
|
"page_token": outData.PageToken,
|
||||||
}
|
}
|
||||||
|
if len(outData.Warnings) > 0 {
|
||||||
|
metadata["warnings"] = outData.Warnings
|
||||||
|
}
|
||||||
|
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
|
||||||
|
|
||||||
timeline := buildMeetingEventTimeline(events)
|
timeline := buildMeetingEventTimeline(events)
|
||||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
if runtime.Format == "ndjson" {
|
||||||
if len(timeline.entries) == 0 {
|
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
|
||||||
fmt.Fprintln(w, "No meeting events.")
|
} else {
|
||||||
return
|
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||||
}
|
renderMeetingEventsCompactPretty(w, outData, timeline)
|
||||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
})
|
||||||
})
|
}
|
||||||
if runtime.Format == "pretty" && pageToken != "" {
|
if runtime.Format == "pretty" && pageToken != "" {
|
||||||
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
||||||
if hasMore {
|
if hasMore {
|
||||||
@@ -123,6 +136,400 @@ var VCMeetingEvents = common.Shortcut{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type meetingEventsOutput struct {
|
||||||
|
Meeting meetingEventsMeeting `json:"meeting"`
|
||||||
|
Identity meetingEventsIdentity `json:"identity"`
|
||||||
|
Events []meetingEventsEvent `json:"events"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
PageToken string `json:"page_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meetingEventsMeeting struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Topic string `json:"topic,omitempty"`
|
||||||
|
MeetingNo string `json:"meeting_no,omitempty"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meetingEventsIdentity struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ParticipantType string `json:"participant_type,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meetingEventsEvent struct {
|
||||||
|
EventID string `json:"event_id,omitempty"`
|
||||||
|
EventType string `json:"event_type,omitempty"`
|
||||||
|
EventTime string `json:"event_time,omitempty"`
|
||||||
|
Actors []meetingEventsIdentity `json:"actors,omitempty"`
|
||||||
|
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type meetingEventsEndSignal struct {
|
||||||
|
Ended bool
|
||||||
|
EndTime time.Time
|
||||||
|
HasEndTime bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
|
||||||
|
output := meetingEventsOutput{
|
||||||
|
Meeting: meetingEventsMeetingFromPayload(nil),
|
||||||
|
Identity: identity,
|
||||||
|
HasMore: common.GetBool(data, "has_more"),
|
||||||
|
PageToken: common.GetString(data, "page_token"),
|
||||||
|
}
|
||||||
|
for _, warning := range warnings {
|
||||||
|
if warning = strings.TrimSpace(warning); warning != "" {
|
||||||
|
output.Warnings = append(output.Warnings, warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range events {
|
||||||
|
event, _ := raw.(map[string]interface{})
|
||||||
|
if event == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := common.GetMap(event, "payload")
|
||||||
|
if meeting := common.GetMap(payload, "meeting"); meeting != nil {
|
||||||
|
output.Meeting = meetingEventsMeetingFromPayload(meeting)
|
||||||
|
}
|
||||||
|
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
|
||||||
|
}
|
||||||
|
applyMeetingEventsEndSignal(&output.Meeting, meetingEventsEndSignalFromEvents(events))
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
|
||||||
|
if runtime.As() == core.AsBot {
|
||||||
|
botInfo, err := runtime.BotInfo()
|
||||||
|
if err != nil {
|
||||||
|
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
|
||||||
|
}
|
||||||
|
return meetingEventsBotIdentity(botInfo), ""
|
||||||
|
}
|
||||||
|
userOpenID := strings.TrimSpace(runtime.UserOpenId())
|
||||||
|
identity := meetingEventsIdentity{
|
||||||
|
ID: userOpenID,
|
||||||
|
Name: strings.TrimSpace(runtime.Config.UserName),
|
||||||
|
ParticipantType: "human",
|
||||||
|
}
|
||||||
|
identity.Label = identityLabel(identity)
|
||||||
|
if userOpenID == "" {
|
||||||
|
return identity, "identity unavailable: current user open_id is unavailable"
|
||||||
|
}
|
||||||
|
return identity, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
|
||||||
|
if botInfo == nil {
|
||||||
|
return meetingEventsIdentity{ParticipantType: "bot", Label: "bot"}
|
||||||
|
}
|
||||||
|
identity := meetingEventsIdentity{
|
||||||
|
ID: botInfo.OpenID,
|
||||||
|
Name: botInfo.AppName,
|
||||||
|
ParticipantType: "bot",
|
||||||
|
}
|
||||||
|
identity.Label = identityLabel(identity)
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
|
||||||
|
out := meetingEventsMeeting{
|
||||||
|
ID: common.GetString(meeting, "id"),
|
||||||
|
Topic: common.GetString(meeting, "topic"),
|
||||||
|
MeetingNo: common.GetString(meeting, "meeting_no"),
|
||||||
|
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
|
||||||
|
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
|
||||||
|
Status: "unknown",
|
||||||
|
}
|
||||||
|
start, hasStart := parseFlexibleTime(out.StartTime)
|
||||||
|
end, hasEnd := parseFlexibleTime(out.EndTime)
|
||||||
|
if hasStart && !hasEnd {
|
||||||
|
out.Status = "ongoing"
|
||||||
|
}
|
||||||
|
if hasStart && hasEnd {
|
||||||
|
if end.After(start) {
|
||||||
|
out.Status = "ended"
|
||||||
|
} else {
|
||||||
|
out.Status = "ongoing"
|
||||||
|
out.EndTime = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMeetingEventsEndSignal(meeting *meetingEventsMeeting, signal meetingEventsEndSignal) {
|
||||||
|
if meeting == nil || !signal.Ended {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meeting.Status = "ended"
|
||||||
|
if signal.HasEndTime {
|
||||||
|
meeting.EndTime = signal.EndTime.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsEndSignalFromEvents(events []interface{}) meetingEventsEndSignal {
|
||||||
|
var signal meetingEventsEndSignal
|
||||||
|
for _, raw := range events {
|
||||||
|
event, _ := raw.(map[string]interface{})
|
||||||
|
if event == nil || meetingEventType(event) != "participant_left" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := common.GetMap(event, "payload")
|
||||||
|
if payload == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fallbackTime, fallbackOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||||
|
for _, rawItem := range common.GetSlice(payload, "participant_left_items") {
|
||||||
|
item, _ := rawItem.(map[string]interface{})
|
||||||
|
if item == nil || int(common.GetFloat(item, "leave_reason")) != leaveReasonMeetingEnded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signal.Ended = true
|
||||||
|
endTime, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
|
||||||
|
if !ok {
|
||||||
|
endTime, ok = fallbackTime, fallbackOK
|
||||||
|
}
|
||||||
|
if ok && (!signal.HasEndTime || endTime.After(signal.EndTime)) {
|
||||||
|
signal.EndTime = endTime
|
||||||
|
signal.HasEndTime = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return signal
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
|
||||||
|
payload := common.GetMap(event, "payload")
|
||||||
|
out := meetingEventsEvent{
|
||||||
|
EventID: common.GetString(event, "event_id"),
|
||||||
|
EventType: meetingEventType(event),
|
||||||
|
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
out.Actors = eventActors(out.EventType, payload, selfIdentity)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
|
||||||
|
var actors []meetingEventsIdentity
|
||||||
|
addFromItems := func(key, participantKey string) {
|
||||||
|
for _, raw := range common.GetSlice(payload, key) {
|
||||||
|
item, _ := raw.(map[string]interface{})
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if participant := common.GetMap(item, participantKey); participant != nil {
|
||||||
|
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch eventType {
|
||||||
|
case "participant_joined":
|
||||||
|
addFromItems("participant_joined_items", "participant")
|
||||||
|
case "participant_left":
|
||||||
|
addFromItems("participant_left_items", "participant")
|
||||||
|
case "transcript_received":
|
||||||
|
addFromItems("transcript_received_items", "speaker")
|
||||||
|
case "chat_received":
|
||||||
|
addFromItems("chat_received_items", "operator")
|
||||||
|
case "magic_share_started":
|
||||||
|
addFromItems("magic_share_started_items", "operator")
|
||||||
|
case "magic_share_ended":
|
||||||
|
addFromItems("magic_share_ended_items", "operator")
|
||||||
|
}
|
||||||
|
return actors
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
|
||||||
|
identity := meetingEventsIdentity{
|
||||||
|
ID: common.GetString(participant, "id"),
|
||||||
|
Name: common.GetString(participant, "user_name"),
|
||||||
|
ParticipantType: meetingEventsParticipantType(participant),
|
||||||
|
Role: meetingEventsParticipantRole(participant),
|
||||||
|
}
|
||||||
|
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
|
||||||
|
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
|
||||||
|
identity.ParticipantType = "bot"
|
||||||
|
}
|
||||||
|
if selfIdentity.ParticipantType == "bot" && (identity.Role == "" || identity.Role == "participant") {
|
||||||
|
identity.Role = "bot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identity.ParticipantType == "" {
|
||||||
|
identity.ParticipantType = "human"
|
||||||
|
}
|
||||||
|
if identity.Role == "" {
|
||||||
|
identity.Role = "participant"
|
||||||
|
}
|
||||||
|
identity.Label = identityLabel(identity)
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsParticipantType(participant map[string]interface{}) string {
|
||||||
|
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
|
||||||
|
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
switch raw {
|
||||||
|
case "1", "user", "human":
|
||||||
|
return "human"
|
||||||
|
case "2", "bot", "app":
|
||||||
|
return "bot"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsParticipantRole(participant map[string]interface{}) string {
|
||||||
|
if raw := meetingEventsRoleFromParticipantRole(fieldValueString(participant, "role")); raw != "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsParticipantTypeFromUserType(raw string) string {
|
||||||
|
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
switch raw {
|
||||||
|
case "1", "user", "human":
|
||||||
|
return "human"
|
||||||
|
case "2", "10", "bot", "app":
|
||||||
|
return "bot"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsRoleFromParticipantRole(raw string) string {
|
||||||
|
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
switch raw {
|
||||||
|
case "1", "host":
|
||||||
|
return "host"
|
||||||
|
case "2", "co_host", "cohost":
|
||||||
|
return "co_host"
|
||||||
|
case "3", "participant", "attendee":
|
||||||
|
return "participant"
|
||||||
|
case "4", "bot", "app":
|
||||||
|
return "bot"
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsRoleFromEventUserRole(raw string) string {
|
||||||
|
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
switch raw {
|
||||||
|
case "1", "participant", "attendee":
|
||||||
|
return "participant"
|
||||||
|
case "2", "host":
|
||||||
|
return "host"
|
||||||
|
case "4", "bot", "app":
|
||||||
|
return "bot"
|
||||||
|
case "", "0":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldValueString(values map[string]interface{}, key string) string {
|
||||||
|
if values == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch value := values[key].(type) {
|
||||||
|
case string:
|
||||||
|
return value
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(value)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatInt(int64(value), 10)
|
||||||
|
case json.Number:
|
||||||
|
return value.String()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityLabel(identity meetingEventsIdentity) string {
|
||||||
|
name := identity.Name
|
||||||
|
if name == "" {
|
||||||
|
name = identity.ID
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "unknown"
|
||||||
|
}
|
||||||
|
var tags []string
|
||||||
|
if identity.ParticipantType != "" {
|
||||||
|
tags = append(tags, identity.ParticipantType)
|
||||||
|
}
|
||||||
|
if identity.Role != "" && identity.Role != identity.ParticipantType {
|
||||||
|
tags = append(tags, identity.Role)
|
||||||
|
}
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsTimeString(raw string) string {
|
||||||
|
if parsed, ok := parseFlexibleTime(raw); ok {
|
||||||
|
return parsed.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
|
||||||
|
rows := make([]interface{}, 0, len(events)+1)
|
||||||
|
for _, event := range events {
|
||||||
|
row := meetingEventsEventRow(event)
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
if metadata != nil {
|
||||||
|
rows = append(rows, metadata)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingEventsEventRow(event meetingEventsEvent) map[string]interface{} {
|
||||||
|
raw, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"row_type": "event"}
|
||||||
|
}
|
||||||
|
var row map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &row); err != nil {
|
||||||
|
return map[string]interface{}{"row_type": "event"}
|
||||||
|
}
|
||||||
|
row["row_type"] = "event"
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
|
||||||
|
if data.Identity.Label != "" {
|
||||||
|
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
|
||||||
|
}
|
||||||
|
if len(timeline.entries) == 0 {
|
||||||
|
fmt.Fprintln(w, "No meeting events.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||||
|
}
|
||||||
|
|
||||||
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
||||||
if runtime.Bool("page-all") {
|
if runtime.Bool("page-all") {
|
||||||
return maxVCMeetingEventsPageSize, nil
|
return maxVCMeetingEventsPageSize, nil
|
||||||
@@ -323,7 +730,6 @@ type meetingTimelineEntry struct {
|
|||||||
when time.Time
|
when time.Time
|
||||||
hasWhen bool
|
hasWhen bool
|
||||||
sequence int
|
sequence int
|
||||||
group int
|
|
||||||
subject string
|
subject string
|
||||||
description string
|
description string
|
||||||
details []string
|
details []string
|
||||||
@@ -332,7 +738,6 @@ type meetingTimelineEntry struct {
|
|||||||
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||||
timeline := meetingTimeline{}
|
timeline := meetingTimeline{}
|
||||||
var sequence int
|
var sequence int
|
||||||
var group int
|
|
||||||
for _, raw := range events {
|
for _, raw := range events {
|
||||||
event, _ := raw.(map[string]interface{})
|
event, _ := raw.(map[string]interface{})
|
||||||
if event == nil {
|
if event == nil {
|
||||||
@@ -345,11 +750,11 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
|||||||
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
||||||
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
||||||
}
|
}
|
||||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
|
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
|
||||||
timeline.entries = append(timeline.entries, entry)
|
timeline.entries = append(timeline.entries, entry)
|
||||||
}
|
}
|
||||||
group++
|
|
||||||
}
|
}
|
||||||
|
applyMeetingTimelineEndSignal(&timeline, meetingEventsEndSignalFromEvents(events))
|
||||||
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
||||||
left := timeline.entries[i]
|
left := timeline.entries[i]
|
||||||
right := timeline.entries[j]
|
right := timeline.entries[j]
|
||||||
@@ -370,6 +775,24 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
|||||||
return timeline
|
return timeline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyMeetingTimelineEndSignal(timeline *meetingTimeline, signal meetingEventsEndSignal) {
|
||||||
|
if timeline == nil || !signal.Ended {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if signal.HasEndTime {
|
||||||
|
if !timeline.hasStart || signal.EndTime.After(timeline.startTime) {
|
||||||
|
timeline.endTime = signal.EndTime
|
||||||
|
timeline.hasEnd = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeline.hasEnd = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if timeline.hasStart && timeline.hasEnd && !timeline.endTime.After(timeline.startTime) {
|
||||||
|
timeline.hasEnd = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
||||||
if timeline == nil || meeting == nil {
|
if timeline == nil || meeting == nil {
|
||||||
return
|
return
|
||||||
@@ -391,7 +814,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
|
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
|
||||||
payload := common.GetMap(event, "payload")
|
payload := common.GetMap(event, "payload")
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -400,26 +823,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
|
|||||||
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||||
switch eventType {
|
switch eventType {
|
||||||
case "participant_joined":
|
case "participant_joined":
|
||||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||||
case "participant_left":
|
case "participant_left":
|
||||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
|
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
|
||||||
case "transcript_received":
|
case "transcript_received":
|
||||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
|
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
|
||||||
case "chat_received":
|
case "chat_received":
|
||||||
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
|
return chatEntries(payload, eventTime, eventTimeOK, sequence)
|
||||||
case "magic_share_started":
|
case "magic_share_started":
|
||||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||||
case "magic_share_ended":
|
case "magic_share_ended":
|
||||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||||
default:
|
default:
|
||||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||||
items := common.GetSlice(payload, "participant_joined_items")
|
items := common.GetSlice(payload, "participant_joined_items")
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
|
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
|
||||||
}
|
}
|
||||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -432,15 +855,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
|
|||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "未知参会人"
|
subject = "未知参会人"
|
||||||
}
|
}
|
||||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
|
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||||
items := common.GetSlice(payload, "participant_left_items")
|
items := common.GetSlice(payload, "participant_left_items")
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
|
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
|
||||||
}
|
}
|
||||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -453,15 +876,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
|
|||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "未知参会人"
|
subject = "未知参会人"
|
||||||
}
|
}
|
||||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
|
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||||
items := common.GetSlice(payload, "transcript_received_items")
|
items := common.GetSlice(payload, "transcript_received_items")
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
|
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
|
||||||
}
|
}
|
||||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -479,15 +902,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
|
|||||||
if text != "" {
|
if text != "" {
|
||||||
description = text
|
description = text
|
||||||
}
|
}
|
||||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||||
items := common.GetSlice(payload, "chat_received_items")
|
items := common.GetSlice(payload, "chat_received_items")
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
|
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
|
||||||
}
|
}
|
||||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -507,15 +930,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
|
|||||||
} else {
|
} else {
|
||||||
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
||||||
}
|
}
|
||||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||||
items := common.GetSlice(payload, "magic_share_started_items")
|
items := common.GetSlice(payload, "magic_share_started_items")
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
|
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
|
||||||
}
|
}
|
||||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -538,15 +961,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
|
|||||||
if url != "" {
|
if url != "" {
|
||||||
details = append(details, "URL: "+url)
|
details = append(details, "URL: "+url)
|
||||||
}
|
}
|
||||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
|
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
|
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
|
||||||
}
|
}
|
||||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -559,17 +982,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
|
|||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "未知用户"
|
subject = "未知用户"
|
||||||
}
|
}
|
||||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
|
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
|
||||||
}
|
}
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
|
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
|
||||||
entry := meetingTimelineEntry{
|
entry := meetingTimelineEntry{
|
||||||
when: when,
|
when: when,
|
||||||
hasWhen: hasWhen,
|
hasWhen: hasWhen,
|
||||||
sequence: *sequence,
|
sequence: *sequence,
|
||||||
group: group,
|
|
||||||
subject: subject,
|
subject: subject,
|
||||||
description: description,
|
description: description,
|
||||||
details: details,
|
details: details,
|
||||||
@@ -713,9 +1135,9 @@ func needsColon(description string) bool {
|
|||||||
|
|
||||||
func leaveAction(item map[string]interface{}) string {
|
func leaveAction(item map[string]interface{}) string {
|
||||||
switch int(common.GetFloat(item, "leave_reason")) {
|
switch int(common.GetFloat(item, "leave_reason")) {
|
||||||
case 2:
|
case leaveReasonMeetingEnded:
|
||||||
return "因会议结束离开了会议"
|
return "因会议结束离开了会议"
|
||||||
case 3:
|
case leaveReasonKicked:
|
||||||
return "被移出了会议"
|
return "被移出了会议"
|
||||||
default:
|
default:
|
||||||
return "离开了会议"
|
return "离开了会议"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package vc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -54,6 +55,33 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func botInfoStub() *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/bot/v3/info",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"bot": map[string]interface{}{
|
||||||
|
"open_id": "bot_001",
|
||||||
|
"app_name": "Demo Bot",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func botInfoErrorStub() *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/bot/v3/info",
|
||||||
|
Status: 500,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991663,
|
||||||
|
"msg": "bot info unavailable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func participantJoinedEvent() map[string]interface{} {
|
func participantJoinedEvent() map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"event_id": "event-1",
|
"event_id": "event-1",
|
||||||
@@ -73,6 +101,8 @@ func participantJoinedEvent() map[string]interface{} {
|
|||||||
"participant": map[string]interface{}{
|
"participant": map[string]interface{}{
|
||||||
"id": "bot_001",
|
"id": "bot_001",
|
||||||
"user_name": "Demo Bot",
|
"user_name": "Demo Bot",
|
||||||
|
"user_type": 2,
|
||||||
|
"user_role": 4,
|
||||||
},
|
},
|
||||||
"join_time": "2026-04-17T08:00:00Z",
|
"join_time": "2026-04-17T08:00:00Z",
|
||||||
},
|
},
|
||||||
@@ -90,6 +120,36 @@ func participantJoinedEventOngoing() map[string]interface{} {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func participantLeftEventWithReason(leaveReason int) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"event_id": "event-left",
|
||||||
|
"event_type": "participant_left",
|
||||||
|
"event_time": "2026-04-17T07:18:50Z",
|
||||||
|
"payload": map[string]interface{}{
|
||||||
|
"activity_event_type": "participant_left",
|
||||||
|
"meeting": map[string]interface{}{
|
||||||
|
"id": "7628568141510692381",
|
||||||
|
"topic": "项目例会",
|
||||||
|
"meeting_no": "724939760",
|
||||||
|
"start_time": "1776410100",
|
||||||
|
"end_time": "1776410100",
|
||||||
|
},
|
||||||
|
"participant_left_items": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"participant": map[string]interface{}{
|
||||||
|
"id": "bot_001",
|
||||||
|
"user_name": "Demo Bot",
|
||||||
|
"user_type": 2,
|
||||||
|
"user_role": 4,
|
||||||
|
},
|
||||||
|
"leave_time": "1776410330000",
|
||||||
|
"leave_reason": leaveReason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func chatReceivedEvent() map[string]interface{} {
|
func chatReceivedEvent() map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"event_id": "event-2",
|
"event_id": "event-2",
|
||||||
@@ -112,7 +172,7 @@ func chatReceivedEvent() map[string]interface{} {
|
|||||||
"chat_received_items": []interface{}{
|
"chat_received_items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"content": "hello",
|
"content": "hello",
|
||||||
"message_type": 3,
|
"message_type": 1,
|
||||||
"operator": map[string]interface{}{
|
"operator": map[string]interface{}{
|
||||||
"id": "u1",
|
"id": "u1",
|
||||||
"user_name": "Alice",
|
"user_name": "Alice",
|
||||||
@@ -140,7 +200,7 @@ func multiChatReceivedEvent() map[string]interface{} {
|
|||||||
"chat_received_items": []interface{}{
|
"chat_received_items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"content": "第一条\n第二行",
|
"content": "第一条\n第二行",
|
||||||
"message_type": 3,
|
"message_type": 1,
|
||||||
"send_time": "1776408061000",
|
"send_time": "1776408061000",
|
||||||
"operator": map[string]interface{}{
|
"operator": map[string]interface{}{
|
||||||
"id": "u1",
|
"id": "u1",
|
||||||
@@ -149,6 +209,44 @@ func multiChatReceivedEvent() map[string]interface{} {
|
|||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"content": "第二条",
|
"content": "第二条",
|
||||||
|
"message_type": 1,
|
||||||
|
"send_time": "1776408062000",
|
||||||
|
"operator": map[string]interface{}{
|
||||||
|
"id": "u1",
|
||||||
|
"user_name": "Alice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mixedChatAndReactionEvent() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"event_id": "event-reaction",
|
||||||
|
"event_type": "chat_received",
|
||||||
|
"event_time": "2026-04-17T08:05:00Z",
|
||||||
|
"payload": map[string]interface{}{
|
||||||
|
"activity_event_type": "chat_received",
|
||||||
|
"meeting": map[string]interface{}{
|
||||||
|
"id": "7628568141510692381",
|
||||||
|
"topic": "项目例会",
|
||||||
|
"meeting_no": "724939760",
|
||||||
|
"start_time": "1776407700",
|
||||||
|
"end_time": "1776411300",
|
||||||
|
},
|
||||||
|
"chat_received_items": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"content": "hello",
|
||||||
|
"message_type": 1,
|
||||||
|
"send_time": "1776408061000",
|
||||||
|
"operator": map[string]interface{}{
|
||||||
|
"id": "u1",
|
||||||
|
"user_name": "Alice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"content": "OK",
|
||||||
"message_type": 3,
|
"message_type": 3,
|
||||||
"send_time": "1776408062000",
|
"send_time": "1776408062000",
|
||||||
"operator": map[string]interface{}{
|
"operator": map[string]interface{}{
|
||||||
@@ -414,7 +512,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
|
|||||||
"--start", "1710000000",
|
"--start", "1710000000",
|
||||||
"--end", "1710003600",
|
"--end", "1710003600",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -442,7 +540,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
|
|||||||
"--meeting-id", "7628568141510692381",
|
"--meeting-id", "7628568141510692381",
|
||||||
"--page-all",
|
"--page-all",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -457,24 +555,39 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
|||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
||||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
"+meeting-events",
|
"+meeting-events",
|
||||||
"--meeting-id", "7628568141510692381",
|
"--meeting-id", "7628568141510692381",
|
||||||
"--format", "json",
|
"--format", "json",
|
||||||
"--page-all",
|
"--page-all",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
reg.Verify(t)
|
reg.Verify(t)
|
||||||
|
|
||||||
|
var envelope map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||||
|
t.Fatalf("unmarshal stdout: %v: %s", err, stdout.String())
|
||||||
|
}
|
||||||
|
events := common.GetSlice(common.GetMap(envelope, "data"), "events")
|
||||||
|
if got := len(events); got != 2 {
|
||||||
|
t.Fatalf("events len = %d, want 2: %s", got, stdout.String())
|
||||||
|
}
|
||||||
|
for _, raw := range events {
|
||||||
|
event, _ := raw.(map[string]interface{})
|
||||||
|
if _, ok := event["summary"]; ok {
|
||||||
|
t.Fatalf("event should not expose summary: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if _, ok := event["raw"]; ok {
|
||||||
|
t.Fatalf("event should not expose raw: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||||
out = strings.ReplaceAll(out, "\n", "")
|
out = strings.ReplaceAll(out, "\n", "")
|
||||||
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
|
|
||||||
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, `"has_more":false`) {
|
if !strings.Contains(out, `"has_more":false`) {
|
||||||
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
||||||
}
|
}
|
||||||
@@ -483,6 +596,80 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
|||||||
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
|
"+meeting-events",
|
||||||
|
"--meeting-id", "7628568141510692381",
|
||||||
|
"--format", "json",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
reg.Verify(t)
|
||||||
|
|
||||||
|
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||||
|
out = strings.ReplaceAll(out, "\n", "")
|
||||||
|
for _, want := range []string{
|
||||||
|
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","label":"DemoBot[bot]"}`,
|
||||||
|
`"role":"bot"`,
|
||||||
|
`"event_type":"participant_joined"`,
|
||||||
|
`"actors":[`,
|
||||||
|
`"start_time":"2026-04-17T06:35:00Z"`,
|
||||||
|
`"has_more":true`,
|
||||||
|
`"page_token":"1710000000000000000"`,
|
||||||
|
`"events":[`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{
|
||||||
|
`"current_participants":`,
|
||||||
|
`"is_self":`,
|
||||||
|
`"summary":`,
|
||||||
|
`"raw":`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(out, unwanted) {
|
||||||
|
t.Fatalf("json output should not contain %q: %s", unwanted, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||||
|
reg.Register(botInfoErrorStub())
|
||||||
|
|
||||||
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
|
"+meeting-events",
|
||||||
|
"--meeting-id", "7628568141510692381",
|
||||||
|
"--format", "json",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
reg.Verify(t)
|
||||||
|
|
||||||
|
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||||
|
out = strings.ReplaceAll(out, "\n", "")
|
||||||
|
for _, want := range []string{
|
||||||
|
`"event_type":"participant_joined"`,
|
||||||
|
`"identity":{"participant_type":"bot","label":"bot"}`,
|
||||||
|
`"warnings":[`,
|
||||||
|
`identityunavailable`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||||
|
|
||||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
"+meeting-events",
|
"+meeting-events",
|
||||||
@@ -498,26 +685,205 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
|||||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||||
out = strings.ReplaceAll(out, "\n", "")
|
out = strings.ReplaceAll(out, "\n", "")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
`"identity":{"id":"ou_testuser","participant_type":"human","label":"ou_testuser[human]"}`,
|
||||||
`"event_type":"participant_joined"`,
|
`"event_type":"participant_joined"`,
|
||||||
`"has_more":true`,
|
`"has_more":false`,
|
||||||
`"page_token":"1710000000000000000"`,
|
|
||||||
`"events":[`,
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
t.Fatalf("user json output missing %q: %s", want, stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
|
"+meeting-events",
|
||||||
|
"--meeting-id", "7628568141510692381",
|
||||||
|
"--format", "json",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
reg.Verify(t)
|
||||||
|
|
||||||
|
var envelope map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||||
|
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
data := common.GetMap(envelope, "data")
|
||||||
|
meeting := common.GetMap(data, "meeting")
|
||||||
|
if got := common.GetString(meeting, "status"); got != "ongoing" {
|
||||||
|
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
|
||||||
|
}
|
||||||
|
if _, ok := meeting["end_time"]; ok {
|
||||||
|
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMeetingEventsOutput_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||||
|
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||||
|
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got := out.Meeting.Status; got != "ended" {
|
||||||
|
t.Fatalf("meeting status = %q, want ended", got)
|
||||||
|
}
|
||||||
|
if got := out.Meeting.EndTime; got != "2026-04-17T07:18:50Z" {
|
||||||
|
t.Fatalf("meeting end_time = %q, want leave time", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMeetingEventsOutput_NormalLeaveReasonDoesNotEndMeeting(t *testing.T) {
|
||||||
|
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||||
|
participantLeftEventWithReason(leaveReasonUserLeft),
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got := out.Meeting.Status; got != "ongoing" {
|
||||||
|
t.Fatalf("meeting status = %q, want ongoing", got)
|
||||||
|
}
|
||||||
|
if got := out.Meeting.EndTime; got != "" {
|
||||||
|
t.Fatalf("meeting end_time = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMeetingEventsPretty_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||||
|
timeline := buildMeetingEventTimeline([]interface{}{
|
||||||
|
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||||
|
})
|
||||||
|
got := renderMeetingEventsPretty(timeline)
|
||||||
|
|
||||||
|
if strings.Contains(got, "进行中") {
|
||||||
|
t.Fatalf("pretty output should not show ongoing for meeting-ended leave reason: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "会议时间:2026-04-17 15:15:00 - 2026-04-17 15:18:50") {
|
||||||
|
t.Fatalf("pretty output missing derived meeting end window: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMeetingEventsOutput_UsesLatestMeetingSnapshot(t *testing.T) {
|
||||||
|
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||||
|
participantJoinedEventOngoing(),
|
||||||
|
participantJoinedEvent(),
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got := out.Meeting.Status; got != "ended" {
|
||||||
|
t.Fatalf("meeting status = %q, want ended", got)
|
||||||
|
}
|
||||||
|
if got := out.Meeting.EndTime; got != "2026-04-17T07:35:00Z" {
|
||||||
|
t.Fatalf("meeting end_time = %q, want latest ended snapshot", got)
|
||||||
|
}
|
||||||
|
if got := len(out.Events); got != 2 {
|
||||||
|
t.Fatalf("events len = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMeetingEventsOutput_EmptyEventsHasUnknownMeetingStatus(t *testing.T) {
|
||||||
|
out := buildMeetingEventsOutput(map[string]interface{}{}, nil, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got := out.Meeting.Status; got != "unknown" {
|
||||||
|
t.Fatalf("meeting status = %q, want unknown", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsMeetingFromPayload_StartOnlyIsOngoing(t *testing.T) {
|
||||||
|
got := meetingEventsMeetingFromPayload(map[string]interface{}{
|
||||||
|
"id": "m1",
|
||||||
|
"start_time": "1776410100",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got.Status != "ongoing" {
|
||||||
|
t.Fatalf("meeting status = %q, want ongoing", got.Status)
|
||||||
|
}
|
||||||
|
if got.StartTime != "2026-04-17T07:15:00Z" {
|
||||||
|
t.Fatalf("meeting start_time = %q, want normalized RFC3339", got.StartTime)
|
||||||
|
}
|
||||||
|
if got.EndTime != "" {
|
||||||
|
t.Fatalf("meeting end_time = %q, want empty", got.EndTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
|
"+meeting-events",
|
||||||
|
"--meeting-id", "7628568141510692381",
|
||||||
|
"--format", "ndjson",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
reg.Verify(t)
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
|
||||||
|
t.Fatalf("first ndjson row should be event: %s", lines[0])
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{
|
||||||
|
`"summary":`,
|
||||||
|
`"raw":`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(lines[0], unwanted) {
|
||||||
|
t.Fatalf("event ndjson row should not contain %q: %s", unwanted, lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"row_type":"metadata"`,
|
||||||
|
`"has_more":true`,
|
||||||
|
`"page_token":"1710000000000000000"`,
|
||||||
|
`"identity":`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(lines[1], want) {
|
||||||
|
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsEventRows_OmitsEmptyEventFields(t *testing.T) {
|
||||||
|
rows := meetingEventsEventRows([]meetingEventsEvent{
|
||||||
|
{EventType: "unknown_event"},
|
||||||
|
}, nil)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
row, ok := rows[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("row type = %T, want map", rows[0])
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"event_id", "event_time", "actors", "payload"} {
|
||||||
|
if _, exists := row[unwanted]; exists {
|
||||||
|
t.Fatalf("row should omit %q when empty: %#v", unwanted, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := row["row_type"]; got != "event" {
|
||||||
|
t.Fatalf("row_type = %v, want event", got)
|
||||||
|
}
|
||||||
|
if got := row["event_type"]; got != "unknown_event" {
|
||||||
|
t.Fatalf("event_type = %v, want unknown_event", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
"+meeting-events",
|
"+meeting-events",
|
||||||
"--meeting-id", "7628568141510692381",
|
"--meeting-id", "7628568141510692381",
|
||||||
"--format", "json",
|
"--format", "json",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -536,20 +902,54 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
|||||||
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, `"message_type": 3`) {
|
if !strings.Contains(out, `"message_type": 1`) {
|
||||||
t.Fatalf("json output should keep numeric fields: %s", out)
|
t.Fatalf("json output should keep numeric fields: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
|
"+meeting-events",
|
||||||
|
"--meeting-id", "7628568141510692381",
|
||||||
|
"--format", "json",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
reg.Verify(t)
|
||||||
|
|
||||||
|
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||||
|
out = strings.ReplaceAll(out, "\n", "")
|
||||||
|
for _, want := range []string{
|
||||||
|
`"event_type":"chat_received"`,
|
||||||
|
`"chat_received_items":[`,
|
||||||
|
`"content":"OK"`,
|
||||||
|
`"message_type":3`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `"im_post"`) {
|
||||||
|
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
"+meeting-events",
|
"+meeting-events",
|
||||||
"--meeting-id", "7628568141510692381",
|
"--meeting-id", "7628568141510692381",
|
||||||
"--format", "pretty",
|
"--format", "pretty",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -558,11 +958,12 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
|||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
"当前身份:Demo Bot [bot]",
|
||||||
"会议主题:项目例会",
|
"会议主题:项目例会",
|
||||||
"会议时间:2026-04-17 15:15:00(进行中)",
|
"会议时间:2026-04-17 15:15:00(进行中)",
|
||||||
"Demo Bot(bot_001) 加入了会议",
|
"Demo Bot(bot_001) 加入了会议",
|
||||||
"Alice(u1): [reaction] 第一条\\n第二行",
|
"Alice(u1): [text] 第一条\\n第二行",
|
||||||
"Alice(u1): [reaction] 第二条",
|
"Alice(u1): [text] 第二条",
|
||||||
"Bob(u2) 开始共享「共享文档」",
|
"Bob(u2) 开始共享「共享文档」",
|
||||||
"URL: https://example.com/doc",
|
"URL: https://example.com/doc",
|
||||||
"page_token: 1710000000000000000",
|
"page_token: 1710000000000000000",
|
||||||
@@ -582,12 +983,13 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
|||||||
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
"+meeting-events",
|
"+meeting-events",
|
||||||
"--meeting-id", "7628568141510692381",
|
"--meeting-id", "7628568141510692381",
|
||||||
"--format", "pretty",
|
"--format", "pretty",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -606,12 +1008,13 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
|
|||||||
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
reg.Register(meetingEventsStub(nil, false, ""))
|
reg.Register(meetingEventsStub(nil, false, ""))
|
||||||
|
reg.Register(botInfoStub())
|
||||||
|
|
||||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||||
"+meeting-events",
|
"+meeting-events",
|
||||||
"--meeting-id", "7628568141510692381",
|
"--meeting-id", "7628568141510692381",
|
||||||
"--format", "pretty",
|
"--format", "pretty",
|
||||||
"--as", "user",
|
"--as", "bot",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -850,9 +1253,9 @@ func TestLeaveAction(t *testing.T) {
|
|||||||
item map[string]interface{}
|
item map[string]interface{}
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
|
{name: "meeting ended", item: map[string]interface{}{"leave_reason": leaveReasonMeetingEnded}, want: "因会议结束离开了会议"},
|
||||||
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
|
{name: "kicked", item: map[string]interface{}{"leave_reason": leaveReasonKicked}, want: "被移出了会议"},
|
||||||
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
|
{name: "default", item: map[string]interface{}{"leave_reason": leaveReasonUserLeft}, want: "离开了会议"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -884,6 +1287,70 @@ func TestMeetingEventUserWithID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
|
||||||
|
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||||
|
"id": "u1",
|
||||||
|
"user_name": "Alice",
|
||||||
|
"user_type": 1,
|
||||||
|
"user_role": 2,
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got.ParticipantType != "human" || got.Role != "host" {
|
||||||
|
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
|
||||||
|
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||||
|
"id": "u1",
|
||||||
|
"user_name": "Alice",
|
||||||
|
"user_type": 1,
|
||||||
|
"user_role": 1,
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got.Role != "participant" {
|
||||||
|
t.Fatalf("identity = %#v, want role=participant", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
|
||||||
|
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||||
|
"id": "ou_app",
|
||||||
|
"user_name": "Demo Bot",
|
||||||
|
"user_type": 10,
|
||||||
|
"user_role": 1,
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got.ParticipantType != "bot" {
|
||||||
|
t.Fatalf("identity = %#v, want participant_type=bot", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsIdentityFromParticipant_UnknownUserType(t *testing.T) {
|
||||||
|
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||||
|
"id": "u_unknown",
|
||||||
|
"user_name": "Unknown",
|
||||||
|
"user_type": 0,
|
||||||
|
"user_role": 1,
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got.ParticipantType != "unknown" {
|
||||||
|
t.Fatalf("identity = %#v, want participant_type=unknown", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
|
||||||
|
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||||
|
"id": "u1",
|
||||||
|
"user_name": "Alice",
|
||||||
|
"type": "bot",
|
||||||
|
}, meetingEventsIdentity{})
|
||||||
|
|
||||||
|
if got.ParticipantType != "human" {
|
||||||
|
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMeetingEventSummary(t *testing.T) {
|
func TestMeetingEventSummary(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -933,6 +1400,22 @@ func TestMeetingEventSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMeetingEventsEventFromPayloadUsesActivityEventTypeFallback(t *testing.T) {
|
||||||
|
event := participantJoinedEvent()
|
||||||
|
delete(event, "event_type")
|
||||||
|
|
||||||
|
got := meetingEventsEventFromPayload(event, meetingEventsIdentity{})
|
||||||
|
if got.EventType != "participant_joined" {
|
||||||
|
t.Fatalf("EventType = %q, want participant_joined", got.EventType)
|
||||||
|
}
|
||||||
|
if len(got.Actors) != 1 {
|
||||||
|
t.Fatalf("actors len = %d, want 1: %#v", len(got.Actors), got.Actors)
|
||||||
|
}
|
||||||
|
if got.Actors[0].ID != "bot_001" {
|
||||||
|
t.Fatalf("actor id = %q, want bot_001", got.Actors[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEscapePrettyText(t *testing.T) {
|
func TestEscapePrettyText(t *testing.T) {
|
||||||
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
||||||
want := `line1\nline2\t\r\u0007`
|
want := `line1\nline2\t\r\u0007`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package wiki
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -26,3 +27,17 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
|||||||
}
|
}
|
||||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendWikiProblemHint(err error, hint string) error {
|
||||||
|
if strings.TrimSpace(hint) == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
|
if strings.TrimSpace(p.Hint) != "" {
|
||||||
|
p.Hint = p.Hint + "\n" + hint
|
||||||
|
} else {
|
||||||
|
p.Hint = hint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -130,6 +132,155 @@ 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 TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||||
|
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if token != opaqueNodeToken {
|
||||||
|
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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) {
|
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
@@ -137,14 +288,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
|||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"has_more": false,
|
"has_more": false,
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"space_id": "space_123",
|
"space_id": "7211568716812369922",
|
||||||
"node_token": "wik_node_1",
|
"node_token": "wik_node_1",
|
||||||
"obj_token": "docx_1",
|
"obj_token": "docx_1",
|
||||||
"obj_type": "docx",
|
"obj_type": "docx",
|
||||||
@@ -154,7 +305,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
|||||||
"has_child": true,
|
"has_child": true,
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"space_id": "space_123",
|
"space_id": "7211568716812369922",
|
||||||
"node_token": "wik_node_2",
|
"node_token": "wik_node_2",
|
||||||
"obj_token": "docx_2",
|
"obj_token": "docx_2",
|
||||||
"obj_type": "docx",
|
"obj_type": "docx",
|
||||||
@@ -170,7 +321,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||||
}, factory, stdout)
|
}, factory, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||||
@@ -208,21 +359,22 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
|||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||||
|
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||||
|
|
||||||
stub := &httpmock.Stub{
|
stub := &httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
|
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"has_more": false,
|
"has_more": false,
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"space_id": "space_123",
|
"space_id": "7211568716812369922",
|
||||||
"node_token": "wik_child",
|
"node_token": "wik_child",
|
||||||
"obj_token": "docx_child",
|
"obj_token": "docx_child",
|
||||||
"obj_type": "docx",
|
"obj_type": "docx",
|
||||||
"parent_node_token": "wik_parent",
|
"parent_node_token": parentNodeToken,
|
||||||
"node_type": "origin",
|
"node_type": "origin",
|
||||||
"title": "Child Doc",
|
"title": "Child Doc",
|
||||||
"has_child": false,
|
"has_child": false,
|
||||||
@@ -235,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
|||||||
reg.Register(stub)
|
reg.Register(stub)
|
||||||
|
|
||||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||||
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
|
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
|
||||||
}, factory, stdout)
|
}, factory, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||||
@@ -257,8 +409,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
|||||||
if len(envelope.Data.Nodes) != 1 {
|
if len(envelope.Data.Nodes) != 1 {
|
||||||
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
|
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
|
||||||
}
|
}
|
||||||
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
|
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
|
||||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
|
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +438,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
|||||||
"code": 0, "msg": "success",
|
"code": 0, "msg": "success",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"space": map[string]interface{}{
|
"space": map[string]interface{}{
|
||||||
"space_id": "space_personal_42",
|
"space_id": "7211568716812369923",
|
||||||
"name": "My Library",
|
"name": "My Library",
|
||||||
"space_type": "my_library",
|
"space_type": "my_library",
|
||||||
},
|
},
|
||||||
@@ -296,14 +448,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
|||||||
// Step 2: list nodes in the resolved space.
|
// Step 2: list nodes in the resolved space.
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
|
URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0, "msg": "success",
|
"code": 0, "msg": "success",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"has_more": false,
|
"has_more": false,
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"space_id": "space_personal_42",
|
"space_id": "7211568716812369923",
|
||||||
"node_token": "wik_personal_1",
|
"node_token": "wik_personal_1",
|
||||||
"title": "Personal Note",
|
"title": "Personal Note",
|
||||||
},
|
},
|
||||||
@@ -334,8 +486,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
|||||||
if envelope.Meta.Count != 1 {
|
if envelope.Meta.Count != 1 {
|
||||||
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
|
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
|
||||||
}
|
}
|
||||||
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
|
if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
|
||||||
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
|
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,21 +910,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
|
|||||||
// test pins down the "default = single page" contract.
|
// test pins down the "default = single page" contract.
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0, "msg": "success",
|
"code": 0, "msg": "success",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"has_more": true,
|
"has_more": true,
|
||||||
"page_token": "tok_next",
|
"page_token": "tok_next",
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
|
map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||||
}, factory, stdout)
|
}, factory, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||||
@@ -802,14 +954,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
|||||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0, "msg": "success",
|
"code": 0, "msg": "success",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"has_more": false,
|
"has_more": false,
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"space_id": "space_123",
|
"space_id": "7211568716812369922",
|
||||||
"node_token": "wik_1",
|
"node_token": "wik_1",
|
||||||
"obj_type": "docx",
|
"obj_type": "docx",
|
||||||
"obj_token": "docx_1",
|
"obj_token": "docx_1",
|
||||||
@@ -822,7 +974,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||||
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
|
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
|
||||||
}, factory, stdout)
|
}, factory, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
|
|||||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||||
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
|
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
|
||||||
},
|
},
|
||||||
@@ -235,29 +235,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
|||||||
).WithParam("--node-token")
|
).WithParam("--node-token")
|
||||||
} else {
|
} else {
|
||||||
spec.Token = tokenInput
|
spec.Token = tokenInput
|
||||||
if looksLikeWikiNodeToken(spec.Token) {
|
if spec.ObjType == "" {
|
||||||
spec.SourceKind = "raw-node"
|
spec.SourceKind = "raw-node"
|
||||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
|
||||||
// than silently passing it (the API would just ignore it, but the
|
|
||||||
// mismatch signals caller confusion).
|
|
||||||
if spec.ObjType != "" {
|
|
||||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
|
||||||
spec.Token,
|
|
||||||
).WithParam("--obj-type")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
spec.SourceKind = "raw-obj"
|
spec.SourceKind = "raw-obj"
|
||||||
// A raw obj_token needs an explicit obj_type: get_node would
|
|
||||||
// otherwise default to "doc" and fail confusingly for docx /
|
|
||||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
|
||||||
// as +node-delete instead of deferring to an opaque API error.
|
|
||||||
if spec.ObjType == "" {
|
|
||||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
|
||||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
|
||||||
).WithParam("--obj-type")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,18 +251,6 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
|||||||
return spec, nil
|
return spec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
|
||||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
|
||||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
|
||||||
//
|
|
||||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
|
||||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
|
||||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
|
||||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
|
||||||
func looksLikeWikiNodeToken(token string) bool {
|
|
||||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
|
||||||
}
|
|
||||||
|
|
||||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||||
// don't need one.
|
// don't need one.
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
|
||||||
|
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||||
|
}
|
||||||
|
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||||
|
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
|
||||||
|
}
|
||||||
|
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
|
||||||
|
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -43,23 +59,30 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
if err != nil {
|
||||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
}
|
||||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||||
|
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
if err != nil {
|
||||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||||
|
}
|
||||||
|
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||||
|
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
|
||||||
|
}
|
||||||
|
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
|
||||||
|
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,27 +48,19 @@ var WikiNodeList = common.Shortcut{
|
|||||||
"--space-id my_library is a per-user alias and is only valid with --as user.",
|
"--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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
if _, err := readWikiNodeListSpec(runtime); err != nil {
|
||||||
// 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 err
|
||||||
}
|
}
|
||||||
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
spec, err := readWikiNodeListSpec(runtime)
|
||||||
|
if err != nil {
|
||||||
|
return common.NewDryRunAPI().Set("error", err.Error())
|
||||||
|
}
|
||||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||||
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
if spec.ParentNodeToken != "" {
|
||||||
params["parent_node_token"] = pt
|
params["parent_node_token"] = spec.ParentNodeToken
|
||||||
}
|
}
|
||||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||||
params["page_token"] = pt
|
params["page_token"] = pt
|
||||||
@@ -80,7 +72,7 @@ var WikiNodeList = common.Shortcut{
|
|||||||
// When the caller passes my_library, +node-list must first resolve it
|
// When the caller passes my_library, +node-list must first resolve it
|
||||||
// to the real per-user space_id before listing nodes, mirroring the
|
// to the real per-user space_id before listing nodes, mirroring the
|
||||||
// two-step orchestration used by +node-create.
|
// two-step orchestration used by +node-create.
|
||||||
if spaceID == wikiMyLibrarySpaceID {
|
if spec.SpaceID == wikiMyLibrarySpaceID {
|
||||||
return d.
|
return d.
|
||||||
Desc("2-step orchestration: resolve my_library -> list nodes").
|
Desc("2-step orchestration: resolve my_library -> list nodes").
|
||||||
GET("/open-apis/wiki/v2/spaces/my_library").
|
GET("/open-apis/wiki/v2/spaces/my_library").
|
||||||
@@ -91,13 +83,17 @@ var WikiNodeList = common.Shortcut{
|
|||||||
Set("space_id", "<resolved_space_id>")
|
Set("space_id", "<resolved_space_id>")
|
||||||
}
|
}
|
||||||
return d.
|
return d.
|
||||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
|
||||||
Params(params).
|
Params(params).
|
||||||
Set("space_id", spaceID)
|
Set("space_id", spec.SpaceID)
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
warnIfConflictingPagingFlags(runtime)
|
warnIfConflictingPagingFlags(runtime)
|
||||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
spec, err := readWikiNodeListSpec(runtime)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
spaceID := spec.SpaceID
|
||||||
|
|
||||||
// Resolve the my_library alias to the per-user real space_id before
|
// Resolve the my_library alias to the per-user real space_id before
|
||||||
// listing, so the subsequent request hits a concrete space endpoint.
|
// listing, so the subsequent request hits a concrete space endpoint.
|
||||||
@@ -110,7 +106,7 @@ var WikiNodeList = common.Shortcut{
|
|||||||
spaceID = resolved
|
spaceID = resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -127,10 +123,99 @@ var WikiNodeList = common.Shortcut{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
|
type wikiNodeListSpec struct {
|
||||||
|
SpaceID string
|
||||||
|
ParentNodeToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
|
||||||
|
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||||
|
// my_library is a per-user personal-library alias; it has no meaning
|
||||||
|
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||||
|
// hint instead of deferring to API-time errors. Matches the contract
|
||||||
|
// used by +node-create and +move.
|
||||||
|
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||||
|
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
|
||||||
|
}
|
||||||
|
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
|
||||||
|
return wikiNodeListSpec{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
|
||||||
|
if err != nil {
|
||||||
|
return wikiNodeListSpec{}, err
|
||||||
|
}
|
||||||
|
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWikiNodeListSpaceID(spaceID string) error {
|
||||||
|
if spaceID == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||||
|
}
|
||||||
|
if spaceID == wikiMyLibrarySpaceID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--space-id must be a numeric wiki space_id, not a URL or path",
|
||||||
|
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
|
||||||
|
}
|
||||||
|
if !isDecimalWikiSpaceID(spaceID) {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
|
||||||
|
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
|
||||||
|
}
|
||||||
|
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDecimalWikiSpaceID(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||||
|
if parentNodeToken == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if strings.Contains(parentNodeToken, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(parentNodeToken)
|
||||||
|
if !ok {
|
||||||
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--parent-node-token URL is unsupported",
|
||||||
|
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
|
||||||
|
}
|
||||||
|
if ref.Type != "wiki" {
|
||||||
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--parent-node-token must identify a wiki node; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
|
||||||
|
}
|
||||||
|
parentNodeToken = ref.Token
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(parentNodeToken, "/?#") {
|
||||||
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||||
|
).WithParam("--parent-node-token")
|
||||||
|
}
|
||||||
|
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parentNodeToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
|
||||||
pageSize := runtime.Int("page-size")
|
pageSize := runtime.Int("page-size")
|
||||||
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||||
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
|
|
||||||
auto := wikiListShouldAutoPaginate(runtime)
|
auto := wikiListShouldAutoPaginate(runtime)
|
||||||
pageLimit := runtime.Int("page-limit")
|
pageLimit := runtime.Int("page-limit")
|
||||||
|
|
||||||
@@ -153,7 +238,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
|||||||
}
|
}
|
||||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, "", err
|
return nil, false, "", wikiNodeListProblem(err, runtime)
|
||||||
}
|
}
|
||||||
items, _ := data["items"].([]interface{})
|
items, _ := data["items"].([]interface{})
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -177,6 +262,36 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
|||||||
return nodes, lastHasMore, lastPageToken, nil
|
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{} {
|
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"space_id": common.GetString(m, "space_id"),
|
"space_id": common.GetString(m, "space_id"),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||||
3. 用户确认后 → `*.batch_trash`
|
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||||
|
|
||||||
## 身份选择:优先使用 user 身份
|
## 身份选择:优先使用 user 身份
|
||||||
|
|
||||||
@@ -82,12 +82,13 @@
|
|||||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||||
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
||||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||||
9. **已读回执** —
|
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||||
|
10. **已读回执** —
|
||||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||||
|
|
||||||
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
|
|||||||
|
|
||||||
## 原生 API 调用规则
|
## 原生 API 调用规则
|
||||||
|
|
||||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||||
|
|
||||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ metadata:
|
|||||||
|
|
||||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
||||||
|
|
||||||
|
## 身份与一次性授权
|
||||||
|
|
||||||
|
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli auth login --domain apps
|
||||||
|
```
|
||||||
|
|
||||||
|
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
|
||||||
|
|
||||||
## 意图路由
|
## 意图路由
|
||||||
|
|
||||||
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):
|
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||||
- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
- `--environment` 枚举:`dev` / `online`,**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`)**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||||
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
## 约定(先读)
|
## 约定(先读)
|
||||||
|
|
||||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
- **环境 `--environment dev|online`(可省略)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`、未开多环境的走 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式传 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义,**没有** `--environment`。
|
||||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||||
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
|||||||
|
|
||||||
## Agent 规则
|
## Agent 规则
|
||||||
|
|
||||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`。**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
|
||||||
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
|
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
|
||||||
- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
||||||
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ metadata:
|
|||||||
|
|
||||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||||
|
|
||||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
**CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
|
||||||
|
|
||||||
## 身份
|
## 身份
|
||||||
|
|
||||||
@@ -30,28 +30,84 @@ lark-cli calendar +agenda --as user
|
|||||||
|
|
||||||
| Shortcut | 说明 |
|
| Shortcut | 说明 |
|
||||||
|----------|------|
|
|----------|------|
|
||||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
| `+agenda` | 查看日程安排(默认今天) |
|
||||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
|
||||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
| `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) |
|
||||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||||
|
|
||||||
|
### `+get` — 单日程详情
|
||||||
|
|
||||||
|
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# calendar_id不传,默认primary
|
||||||
|
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||||
|
|
||||||
|
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# query 按关键词 可选
|
||||||
|
# start/end 按时间范围(ISO 8601 或 YYYY-MM-DD)可选
|
||||||
|
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
|
||||||
|
# page-token 分页游标,用于继续翻页 可选
|
||||||
|
# page-size 每页数量,默认 30 可选
|
||||||
|
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### `+agenda` — 查看近期日程安排
|
||||||
|
|
||||||
|
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||||
|
# calendar-id 日历 ID(默认primary)可选
|
||||||
|
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
|
||||||
|
- 时间范围超过 40 天会自动拆分查询并合并结果。
|
||||||
|
|
||||||
|
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
|
||||||
|
|
||||||
|
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||||
|
# user-id 目标用户 open_id(ou_ 前缀)可选;默认当前登录用户,bot 身份必须显式指定
|
||||||
|
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
用法提示:
|
||||||
|
- **仅判断是否有空** → `+freebusy`;**需要日程详情** → `+agenda`。
|
||||||
|
- 检查多人可用性:分别调用并对比,找共同空闲。
|
||||||
|
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
|
||||||
|
|
||||||
## 前置条件路由
|
## 前置条件路由
|
||||||
|
|
||||||
| 场景 | 前置要求 |
|
| 场景 | 前置要求 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
|
||||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||||
|
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||||
|
|
||||||
|
## 写操作反馈
|
||||||
|
|
||||||
|
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||||
|
- **重复性日程例外(Exception)**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
|
||||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||||
@@ -70,7 +126,9 @@ lark-cli calendar +agenda --as user
|
|||||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
| 从日程获取关联的视频会议 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) |
|
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
| 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||||
|
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
|
||||||
|
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||||
|
|
||||||
## 任务类型分流
|
## 任务类型分流
|
||||||
|
|
||||||
@@ -87,7 +145,7 @@ lark-cli calendar +agenda --as user
|
|||||||
|
|
||||||
## 会议室规则
|
## 会议室规则
|
||||||
|
|
||||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)。
|
||||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||||
@@ -95,42 +153,45 @@ lark-cli calendar +agenda --as user
|
|||||||
## API Resources
|
## API Resources
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 通用调用格式
|
||||||
lark-cli calendar <resource> <method> [flags]
|
lark-cli calendar <resource> <method> [flags]
|
||||||
|
|
||||||
|
# 查询用户主日历
|
||||||
|
lark-cli calendar calendars primary
|
||||||
|
|
||||||
|
# 获取日程分享链接
|
||||||
|
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
|
||||||
|
|
||||||
|
# 删除日程
|
||||||
|
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### calendars
|
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
|
||||||
|
|
||||||
- `create` — 创建共享日历
|
### 查询资源的方法列表以及方法的使用方式
|
||||||
- `delete` — 删除共享日历
|
|
||||||
- `get` — 查询日历信息
|
|
||||||
- `list` — 查询日历列表
|
|
||||||
- `patch` — 更新日历信息
|
|
||||||
- `primary` — 查询用户主日历
|
|
||||||
- `search` — 搜索日历
|
|
||||||
|
|
||||||
### event.attendees
|
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
|
||||||
|
- 查看方法的cli flag:`lark-cli calendar <resource> <method> -h`
|
||||||
|
- 查看方法API参数:`lark-cli schema calendar.<resource>.<method>`
|
||||||
|
|
||||||
- `batch_delete` — 删除日程参与人
|
`<resource>` 为 `calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`。
|
||||||
- `create` — 添加日程参与人
|
|
||||||
- `list` — 获取日程参与人列表
|
|
||||||
|
|
||||||
### events
|
## 常用其他域命令
|
||||||
|
|
||||||
- `create` — 创建日程
|
```bash
|
||||||
- `delete` — 删除日程
|
# 搜索用户,更多参数详见 lark-contact
|
||||||
- `get` — 获取日程
|
lark-cli contact +search-user --query <query> --as user
|
||||||
- `instance_view` — 查询日程视图
|
|
||||||
- `patch` — 更新日程
|
|
||||||
- `share_info` — 获取日程分享链接
|
|
||||||
|
|
||||||
### freebusys
|
# 搜索群聊,更多参数详见 lark-im
|
||||||
|
lark-cli im +chat-search --query <query> --as user
|
||||||
- `list` — 查询主日历日程忙闲信息
|
```
|
||||||
|
|
||||||
## 不在本 skill 范围
|
## 不在本 skill 范围
|
||||||
|
|
||||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||||
|
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
|
||||||
|
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
|
||||||
- 会议室物理设施管理 → 管理员后台
|
- 会议室物理设施管理 → 管理员后台
|
||||||
|
|
||||||
**注意(强制性):**
|
**注意(强制性):**
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
|
|
||||||
# calendar +agenda
|
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
查看近期日程安排。只读操作,不修改任何日程。
|
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.event:read"]
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看今天日程(默认)
|
|
||||||
lark-cli calendar +agenda
|
|
||||||
|
|
||||||
# 自定义时间范围(ISO 8601)
|
|
||||||
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
|
||||||
|
|
||||||
# 自定义时间范围(仅日期)
|
|
||||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
|
|
||||||
|
|
||||||
# 人类可读格式输出
|
|
||||||
lark-cli calendar +agenda --format pretty
|
|
||||||
|
|
||||||
# 指定日历
|
|
||||||
lark-cli calendar +agenda --calendar-id cal_xxx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参数
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期,默认当天) |
|
|
||||||
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
|
||||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
|
||||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
|
||||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
|
||||||
|
|
||||||
## 时间格式
|
|
||||||
|
|
||||||
`--start` 和 `--end` 支持以下格式:
|
|
||||||
|
|
||||||
| 格式 | 示例 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
|
|
||||||
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
|
|
||||||
| 仅日期 | `2026-03-10` | start 取 00:00:00,end 取 23:59:59 |
|
|
||||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
|
|
||||||
**将结果整理为易读的日程表:**
|
|
||||||
|
|
||||||
```
|
|
||||||
## 2026-03-10 周一
|
|
||||||
|
|
||||||
09:00 - 09:30 站会
|
|
||||||
10:00 - 11:00 产品评审
|
|
||||||
14:00 - 15:00 与 Alice 1:1
|
|
||||||
|
|
||||||
## 2026-03-11 周二
|
|
||||||
|
|
||||||
(无日程)
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
|
|
||||||
|
|
||||||
## 提示
|
|
||||||
|
|
||||||
- 已取消的日程会自动过滤,无需额外处理。
|
|
||||||
- 如无日程,告知用户"日程清空"。
|
|
||||||
- 大于 40 天的时间范围会自动拆分查询并合并结果。
|
|
||||||
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
|
|
||||||
|
|
||||||
## 参考
|
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
|
|
||||||
# calendar +create
|
# calendar +create
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
创建日程并按需邀请参会人。
|
创建日程并按需邀请参会人。
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
|
|
||||||
|
|
||||||
## 推荐命令
|
## 推荐命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,15 +35,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
|||||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||||
|
|
||||||
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
|
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||||
|
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||||
|
|
||||||
## 高级用法(完整 API 命令)
|
## 高级用法(完整 API 命令)
|
||||||
|
|
||||||
@@ -55,37 +53,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
|||||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 第一步:创建日程(含高级参数)
|
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||||
## 查看完整参数定义
|
|
||||||
lark-cli schema calendar.events.create
|
|
||||||
## 创建日程
|
|
||||||
lark-cli calendar events create \
|
|
||||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
|
||||||
--data '{
|
|
||||||
"summary": "技术分享:CLI 架构设计",
|
|
||||||
"start_time": { "timestamp": "1741586400" },
|
|
||||||
"end_time": { "timestamp": "1741593600" }
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
|
||||||
## 查看完整参数定义
|
|
||||||
lark-cli schema calendar.event.attendees.create
|
|
||||||
## 添加参会人
|
|
||||||
lark-cli calendar event.attendees create \
|
lark-cli calendar event.attendees create \
|
||||||
|
--as user \
|
||||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||||
|
|
||||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
完整 API 命令的关键差异:
|
||||||
## 查看完整参数定义
|
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||||
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)。
|
|
||||||
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
|
|
||||||
|
|
||||||
## 参会人类型
|
## 参会人类型
|
||||||
|
|
||||||
@@ -101,6 +78,5 @@ lark-cli calendar events delete \
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
|
|
||||||
# calendar +freebusy
|
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
|
||||||
|
|
||||||
查询用户主日历的忙闲信息,返回指定时间范围内的忙碌时段列表和rsvp的状态。
|
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查询当前用户今天的忙闲(默认)
|
|
||||||
lark-cli calendar +freebusy
|
|
||||||
|
|
||||||
# 自定义时间范围(仅日期)
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
|
|
||||||
|
|
||||||
# 自定义时间范围(完整 ISO 8601)
|
|
||||||
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
|
|
||||||
|
|
||||||
# 查询指定用户的忙闲信息
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
|
||||||
|
|
||||||
# 人类可读格式输出
|
|
||||||
lark-cli calendar +freebusy --format pretty
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参数
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `--start <time>` | 否 | 查询开始时间(ISO 8601 或仅日期,默认当天) |
|
|
||||||
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
|
||||||
| `--user-id <open_id>` | 否 | 目标查询用户 ID(`ou_` 前缀)。省略时默认查询当前登录用户,bot 身份调用时必须明确指定 |
|
|
||||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
|
||||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
|
||||||
|
|
||||||
## 时间格式
|
|
||||||
|
|
||||||
`--start` 和 `--end` 支持以下格式:
|
|
||||||
|
|
||||||
| 格式 | 示例 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
|
|
||||||
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
|
|
||||||
| 仅日期 | `2026-03-11` | start 取 00:00:00,end 取 23:59:59 |
|
|
||||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
|
||||||
|
|
||||||
## 输出示例
|
|
||||||
|
|
||||||
### 表格格式
|
|
||||||
|
|
||||||
```
|
|
||||||
start end rsvp_status
|
|
||||||
---------------- ---------------- -----------
|
|
||||||
2026-03-11 10:00 2026-03-11 10:30 接受
|
|
||||||
2026-03-11 14:00 2026-03-11 15:00 待定
|
|
||||||
|
|
||||||
共 2 个忙碌时段
|
|
||||||
```
|
|
||||||
|
|
||||||
### JSON 格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"start_time": "2026-03-11T10:00:00+08:00",
|
|
||||||
"end_time": "2026-03-11T10:30:00+08:00",
|
|
||||||
"rsvp_status": "accept"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start_time": "2026-03-11T14:00:00+08:00",
|
|
||||||
"end_time": "2026-03-11T15:00:00+08:00",
|
|
||||||
"rsvp_status": "tentative"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 典型场景
|
|
||||||
|
|
||||||
### 1. 查找日程会议空闲时段
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查询今天的忙碌时段
|
|
||||||
lark-cli calendar +freebusy
|
|
||||||
|
|
||||||
# 查询工作时间段
|
|
||||||
lark-cli calendar +freebusy \
|
|
||||||
--start "2026-03-11T08:00:00+08:00" \
|
|
||||||
--end "2026-03-11T18:00:00+08:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 检查团队成员可用性
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查询多个成员,对比找出共同空闲时间
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
|
|
||||||
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
|
|
||||||
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
|
|
||||||
|
|
||||||
## 与其他命令对比
|
|
||||||
|
|
||||||
| 命令 | 用途 | 输出内容 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
|
|
||||||
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
|
|
||||||
|
|
||||||
**选择建议**:
|
|
||||||
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
|
|
||||||
- **需要查看日程详情** → 使用 `+agenda`
|
|
||||||
|
|
||||||
## 参考
|
|
||||||
|
|
||||||
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
|
|
||||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
|
||||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
|
||||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
|
||||||
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 重复性日程操作规范
|
||||||
|
|
||||||
|
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
|
||||||
|
|
||||||
|
## 关键概念
|
||||||
|
|
||||||
|
- **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) — 认证和全局参数
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
# calendar +room-find
|
# calendar +room-find
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
|
||||||
|
|
||||||
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
||||||
|
|
||||||
需要的 scopes: ["calendar:calendar.free_busy:read"]
|
|
||||||
|
|
||||||
## 适用场景
|
## 适用场景
|
||||||
|
|
||||||
- 已知一个或多个待选时间块,需要查找可用会议室
|
- 已知一个或多个待选时间块,需要查找可用会议室
|
||||||
@@ -50,7 +47,7 @@ lark-cli calendar +room-find \
|
|||||||
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
||||||
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
||||||
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
||||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 |
|
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||||
@@ -67,7 +64,7 @@ lark-cli calendar +room-find \
|
|||||||
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
||||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "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"`。
|
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
||||||
- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
- 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||||
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
||||||
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
||||||
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
||||||
@@ -90,9 +87,8 @@ lark-cli calendar +room-find \
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **AI 行为指导:**
|
> **AI 行为指导:**
|
||||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。
|
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向。
|
||||||
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `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`,以防止会议室预约失败。不能直接按原规则继续。
|
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
||||||
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
||||||
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
||||||
@@ -102,7 +98,7 @@ lark-cli calendar +room-find \
|
|||||||
| 字段名 | 说明 |
|
| 字段名 | 说明 |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
||||||
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
|
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
|
||||||
| `capacity` | 会议室最大容纳人数。 |
|
| `capacity` | 会议室最大容纳人数。 |
|
||||||
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
||||||
|
|
||||||
@@ -110,4 +106,4 @@ lark-cli calendar +room-find \
|
|||||||
|
|
||||||
- [lark-calendar-create](lark-calendar-create.md)
|
- [lark-calendar-create](lark-calendar-create.md)
|
||||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
||||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
# calendar +rsvp
|
# calendar +rsvp
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.event:reply"]
|
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,5 +35,4 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 明确时间分支:room-find + freebusy + 冲突处理
|
||||||
|
|
||||||
|
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||||
|
- 任务类型判定(新建 / 编辑)
|
||||||
|
- 编辑流:目标 event_id 已定位
|
||||||
|
- 新建流:默认值已补全
|
||||||
|
- 时间已判定为**明确**
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
### 1. 查询会议室(如需)
|
||||||
|
|
||||||
|
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli calendar +room-find \
|
||||||
|
--slot "<start>~<end>" \
|
||||||
|
--attendee-ids "<ids>" \
|
||||||
|
--city "<city>" \
|
||||||
|
--building "<building>" \
|
||||||
|
--floor "<F2>" \
|
||||||
|
--room-name "<room_name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
时间块确定规则:
|
||||||
|
- **编辑流且不改时间,只新增会议室**:`--slot` 必须来自已定位日程的当前 `start/end`
|
||||||
|
- **编辑流且既改时间又加会议室**:`--slot` 必须来自候选新时间,而不是旧时间
|
||||||
|
|
||||||
|
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||||
|
|
||||||
|
### 2. 查询忙闲
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||||
|
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||||
|
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||||
|
|
||||||
|
### 3. 冲突处理
|
||||||
|
|
||||||
|
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
|
||||||
|
- **有冲突**:必须先说明冲突情况,询问用户:
|
||||||
|
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
|
||||||
|
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||||
|
|
||||||
|
## 落地
|
||||||
|
|
||||||
|
根据任务类型:
|
||||||
|
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||||
|
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||||
|
|
||||||
|
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 模糊时间 / 无时间信息分支:suggestion + 批量查询
|
||||||
|
|
||||||
|
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||||
|
- 任务类型判定(新建 / 编辑)
|
||||||
|
- 编辑流:目标 event_id 已定位
|
||||||
|
- 新建流:默认值已补全
|
||||||
|
- 时间已判定为**模糊**或**无时间信息**
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
### 1. 调用 suggestion
|
||||||
|
|
||||||
|
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli calendar +suggestion \
|
||||||
|
--start "<range_start>" \
|
||||||
|
--end "<range_end>" \
|
||||||
|
--attendee-ids "<ids>" \
|
||||||
|
--duration-minutes <n> \
|
||||||
|
--event-rrule "<rrule>"
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
|
||||||
|
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
|
||||||
|
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
|
||||||
|
|
||||||
|
### 2. 分支处理
|
||||||
|
|
||||||
|
#### 不需要会议室
|
||||||
|
|
||||||
|
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
|
||||||
|
|
||||||
|
#### 需要会议室
|
||||||
|
|
||||||
|
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
|
||||||
|
|
||||||
|
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
|
||||||
|
|
||||||
|
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||||
|
|
||||||
|
### 3. 用户确认后
|
||||||
|
|
||||||
|
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
|
||||||
|
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
|
||||||
|
|
||||||
|
## 模糊语义消解与长期记忆
|
||||||
|
|
||||||
|
针对存在歧义的时间场景,严禁主观臆断。典型例子:
|
||||||
|
- "上班后" / "下班前"
|
||||||
|
- 未明确上下午的 12 小时制时间
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
- 主动澄清真实意图,不自行猜测
|
||||||
|
- 用户澄清后,将个性化定义沉淀为长期偏好
|
||||||
|
|
||||||
|
## 用户展示格式
|
||||||
|
|
||||||
|
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## 2026-03-27 周五
|
||||||
|
|
||||||
|
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||||
|
可用会议室:
|
||||||
|
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||||
|
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||||
|
|
||||||
|
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||||
|
可用会议室:
|
||||||
|
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||||
|
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||||
|
|
||||||
|
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 落地
|
||||||
|
|
||||||
|
根据任务类型:
|
||||||
|
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||||
|
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||||
|
|
||||||
|
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||||
@@ -1,206 +1,95 @@
|
|||||||
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
||||||
|
|
||||||
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
|
## 执行摘要
|
||||||
|
|
||||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
|
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
|
||||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
|
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
|
||||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
|
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。
|
||||||
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
|
- **新建流先补默认值,编辑流先继承已定位日程信息。**
|
||||||
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
|
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
|
||||||
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
|
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||||
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
|
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程。
|
||||||
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。
|
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤。
|
||||||
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
|
|
||||||
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
|
|
||||||
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
|
|
||||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
|
|
||||||
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
|
|
||||||
|
|
||||||
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
|
|
||||||
|
|
||||||
## 严禁行为
|
## 严禁行为
|
||||||
|
|
||||||
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
|
- **严禁在未读取对应子命令文档前直接调用命令。**
|
||||||
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
|
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
|
||||||
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
|
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
|
||||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
|
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`。
|
||||||
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。
|
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
|
||||||
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。
|
|
||||||
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
|
|
||||||
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
|
|
||||||
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
|
|
||||||
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
|
|
||||||
|
|
||||||
## 适用场景
|
## 适用场景
|
||||||
|
|
||||||
- “帮我约个会”
|
- "帮我约个会" / "下周找时间和 XX 开会"
|
||||||
- “下周找时间和 XX 开会”
|
- "帮我订/找/搜索一个可用会议室"
|
||||||
- “帮我订个会议室”
|
- "明天下午3点约个日程"
|
||||||
- “帮我找/搜索一个可用的会议室”
|
- "把明天上午的日程加上 小明"
|
||||||
- “帮我推荐一个我以前常用的会议室”
|
- "给下周一的周会换个会议室"
|
||||||
- “查询明天下午可用的会议室”
|
- "把这个日程改到明天下午,并加上学清 F201"
|
||||||
- “明天下午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`。
|
|
||||||
|
|
||||||
### 2. 新建日程:智能推断默认值
|
- **标题**:根据上下文自动生成;如无法推断,默认"会议"
|
||||||
以下信息智能推断,减少频繁询问用户:
|
- **参会人**:如未指定,默认仅用户自己
|
||||||
|
- **时长**:基于上下文推断;默认 30 分钟
|
||||||
|
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
|
||||||
|
|
||||||
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
|
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
|
||||||
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
|
|
||||||
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
|
|
||||||
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
|
|
||||||
|
|
||||||
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
|
## 判断时间是否明确
|
||||||
|
|
||||||
### 3. 判断时间是否明确
|
|
||||||
|
|
||||||
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
|
|
||||||
|
|
||||||
时间基准规则:
|
时间基准规则:
|
||||||
|
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
|
||||||
|
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
|
||||||
|
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
|
||||||
|
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
|
||||||
|
|
||||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
|
## 分支路由
|
||||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
|
|
||||||
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。
|
|
||||||
|
|
||||||
分两类处理:
|
| 判定结果 | 下一步读取 |
|
||||||
|
|----------|-----------|
|
||||||
|
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
|
||||||
|
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
|
||||||
|
|
||||||
- **明确时间**:如“明天下午3点”
|
## 落地日程变更
|
||||||
- **模糊时间**:如“明天下午”“下周找个时间”
|
|
||||||
|
|
||||||
### 4. 明确时间
|
|
||||||
|
|
||||||
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
|
|
||||||
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 如果需要会议室,提前查询会议室
|
|
||||||
lark-cli calendar +room-find \
|
|
||||||
--slot "<start>~<end>" \
|
|
||||||
--attendee-ids "<ids>" \
|
|
||||||
--city "<city>" \
|
|
||||||
--building "<building>" \
|
|
||||||
--floor "<F2>" \
|
|
||||||
--room-name "<room_name>"
|
|
||||||
|
|
||||||
# 2. 查询当前用户及其他参会人忙闲
|
|
||||||
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>")
|
|
||||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- **参会人过多或包含群组时的处理**:
|
|
||||||
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
|
|
||||||
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
|
|
||||||
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。
|
|
||||||
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
|
|
||||||
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
|
||||||
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
|
|
||||||
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
|
|
||||||
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
|
||||||
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。
|
|
||||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
|
||||||
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。
|
|
||||||
- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。
|
|
||||||
|
|
||||||
### 5. 模糊时间或无时间信息
|
|
||||||
|
|
||||||
先调用:
|
|
||||||
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lark-cli calendar +suggestion \
|
|
||||||
--start "<range_start>" \
|
|
||||||
--end "<range_end>" \
|
|
||||||
--attendee-ids "<ids>" \
|
|
||||||
--duration-minutes <n> \
|
|
||||||
--event-rrule "<rrule>"
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
|
|
||||||
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
|
|
||||||
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
|
|
||||||
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
|
|
||||||
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
|
|
||||||
|
|
||||||
### 6. 模糊语义消解与长期记忆构建
|
|
||||||
|
|
||||||
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
|
|
||||||
|
|
||||||
- “上班后”
|
|
||||||
- “下班前”
|
|
||||||
- 未明确上下午的 12 小时制时间表达
|
|
||||||
|
|
||||||
处理规则:
|
|
||||||
|
|
||||||
- 应主动澄清真实意图,而不是自行猜测
|
|
||||||
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
|
|
||||||
|
|
||||||
### 7. 重复性日程
|
|
||||||
|
|
||||||
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。
|
|
||||||
|
|
||||||
必须检查返回中的:
|
|
||||||
|
|
||||||
- `reserve_until_time`
|
|
||||||
|
|
||||||
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
|
|
||||||
|
|
||||||
- 向用户明确说明该会议室最长可约至何时。
|
|
||||||
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
|
|
||||||
|
|
||||||
### 8. 落地日程变更
|
|
||||||
|
|
||||||
用户确认后调用:
|
用户确认后调用:
|
||||||
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
|
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||||
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
|
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lark-cli calendar +create \
|
lark-cli calendar +create \
|
||||||
@@ -214,52 +103,20 @@ lark-cli calendar +update \
|
|||||||
--start "<start>" \
|
--start "<start>" \
|
||||||
--end "<end>" \
|
--end "<end>" \
|
||||||
--add-attendee-ids "omm_new_room"
|
--add-attendee-ids "omm_new_room"
|
||||||
|
|
||||||
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
|
|
||||||
lark-cli calendar +update \
|
|
||||||
--event-id "<event_id>" \
|
|
||||||
--remove-attendee-ids "omm_old_room" \
|
|
||||||
--add-attendee-ids "omm_new_room"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
规则:
|
落地规则:
|
||||||
- 新建日程时,可使用 `+create`
|
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
|
||||||
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
|
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
|
||||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
|
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids` 新
|
||||||
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。
|
- 需要会议室时,将选中的 `room_id` 写入参与人列表
|
||||||
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+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-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-suggestion.md](./lark-calendar-suggestion.md)
|
||||||
- [lark-calendar-create.md](./lark-calendar-create.md)
|
- [lark-calendar-create.md](./lark-calendar-create.md)
|
||||||
- [lark-shared](../../lark-shared/SKILL.md)
|
- [lark-calendar-update.md](./lark-calendar-update.md)
|
||||||
- [lark-calendar](../SKILL.md)
|
- [SKILL.md](../SKILL.md)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
# calendar +search-event
|
|
||||||
|
|
||||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 按关键词
|
|
||||||
lark-cli calendar +search-event --query "周会"
|
|
||||||
|
|
||||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
|
||||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
|
||||||
|
|
||||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
|
||||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
|
||||||
|
|
||||||
# 组合
|
|
||||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 输出字段
|
|
||||||
|
|
||||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
|
||||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# calendar +suggestion
|
# calendar +suggestion
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
|
||||||
|
|
||||||
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
|
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
|
||||||
|
|
||||||
@@ -8,8 +7,6 @@
|
|||||||
- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
|
- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
|
||||||
- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
|
- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -121,5 +118,4 @@ lark-cli calendar +suggestion \
|
|||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
||||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
|
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
# calendar +update
|
# calendar +update
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
更新既有日程字段,或独立增量添加/移除参会人和会议室。
|
更新既有日程字段,或独立增量添加/移除参会人和会议室。
|
||||||
|
|
||||||
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
|
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
|
||||||
|
|
||||||
需要的 scopes: ["calendar:calendar.event:update"]
|
|
||||||
|
|
||||||
## 推荐命令
|
## 推荐命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -43,7 +40,7 @@ lark-cli calendar +update \
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||||
@@ -65,9 +62,9 @@ lark-cli calendar +update \
|
|||||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||||
- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。
|
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
|
||||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||||
|
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
|
||||||
|
|
||||||
## 高级用法(完整 API 命令)
|
## 高级用法(完整 API 命令)
|
||||||
|
|
||||||
@@ -98,8 +95,6 @@ lark-cli calendar +update \
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
|
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
|
||||||
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
|
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
|
||||||
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
|
|||||||
</whiteboard>
|
</whiteboard>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`;CLI 会在写入前读取文件并展开为内联内容。
|
||||||
|
|
||||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||||
|
|
||||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||||
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
|
|||||||
</whiteboard>
|
</whiteboard>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`;PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`。
|
||||||
|
|
||||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||||
|
|
||||||
- doc token、插入位置(标题 / block_id / command)
|
- doc token、插入位置(标题 / block_id / command)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user