mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
28 Commits
feat/slide
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c3649633d | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 | ||
|
|
d0cde9a414 | ||
|
|
075b34f9a3 | ||
|
|
3788405256 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,6 +2,65 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.66] - 2026-07-06
|
||||
|
||||
### Features
|
||||
|
||||
- support semantic recurring calendar operations (#1723)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- guide drive import concurrency conflicts (#1751)
|
||||
- **calendar**: guide approval room booking fallback (#1637)
|
||||
- support pnpm global installs in self-update (#1705)
|
||||
|
||||
### 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)
|
||||
|
||||
## [v1.0.65] - 2026-07-03
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
@@ -1333,6 +1392,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### JSON Output Contract
|
||||
|
||||
With `--format json` (the default), success and error envelopes are distinct.
|
||||
|
||||
Success goes to **stdout**, exit code `0`:
|
||||
|
||||
```json
|
||||
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||
```
|
||||
|
||||
Errors go to **stderr**, non-zero exit code:
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
```
|
||||
|
||||
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||
|
||||
### Pagination
|
||||
|
||||
```bash
|
||||
|
||||
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 # 逗号分隔值
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -20,13 +20,28 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
cmd := NewCmdApi(f, runF)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestRootCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
// Should return an error
|
||||
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
pm := "npm"
|
||||
install := updater.RunNpmInstall
|
||||
if detect.Method == selfupdate.InstallPnpm {
|
||||
pm = "pnpm"
|
||||
install = updater.RunPnpmInstall
|
||||
}
|
||||
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
npmResult := install(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
"hint": permissionHint(combined, pm),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
if hint := permissionHint(combined, pm); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
hint := verificationFailureHint(updater, latest, pm)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if skillsResult != nil {
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
skillsPM := "npx"
|
||||
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||
skillsPM = "pnpm dlx"
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
func permissionHint(pmOutput, pm string) string {
|
||||
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
if pm == "pnpm" {
|
||||
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||
}
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
if pm == "pnpm" {
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||
// and fails the test if the npm install path is invoked.
|
||||
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.PnpmInstallOverride = pnpmFn
|
||||
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||
t.Errorf("npm install must not be called for a pnpm install")
|
||||
return &selfupdate.NpmResult{}
|
||||
}
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "via pnpm") {
|
||||
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndPnpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||
)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error exit")
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||
t.Errorf("expected failure envelope, got: %s", out)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "pnpm add -g") {
|
||||
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||
}
|
||||
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
hint = permissionHint("EACCES: permission denied", "npm")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
if got := permissionHint("some other error", "npm"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`CategoryPolicy`.
|
||||
|
||||
### Success envelope (stdout)
|
||||
|
||||
For contrast: success responses render to **stdout** as an
|
||||
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": { "guid": "e297d3d0-..." },
|
||||
"meta": { "count": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
Consumers must branch on `ok` (or the process exit code). The success
|
||||
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||
will misclassify every successful call as a failure, which is
|
||||
especially dangerous around write commands (e.g. retrying a create that
|
||||
already succeeded).
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | When | Exit | Typed struct |
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||
WithMissingScopes("mail:user_mailbox.message:send").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||
WithMissingScopes("calendar:event:create").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"identity": "user",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
|
||||
@@ -6,12 +6,10 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -40,8 +38,6 @@ const (
|
||||
BuildKindUnknown = "unknown"
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
||||
return SourceValue + "/" + build.Version
|
||||
}
|
||||
|
||||
// AgentTraceValue returns a header-safe value from the
|
||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
||||
// surrounding whitespace, rejects values containing any Unicode
|
||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
||||
// for any invalid or empty value. Callers can use the result
|
||||
// directly in HTTP headers without further sanitisation.
|
||||
func AgentTraceValue() string {
|
||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
||||
if v == "" || len(v) > agentTraceMaxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BaseSecurityHeaders returns headers that every request must carry.
|
||||
func BaseSecurityHeaders() http.Header {
|
||||
h := make(http.Header)
|
||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
return h
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentTraceValue / HeaderAgentTrace
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTraceValue(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, " ")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
||||
if got := AgentTraceValue(); got != "" {
|
||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(envvars.CliAgentTrace, val)
|
||||
if got := AgentTraceValue(); got != val {
|
||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// Content safety scanning mode
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
|
||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
agentNameMaxLen = 128
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
func AgentName() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||
}
|
||||
|
||||
func AgentTrace() string {
|
||||
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||
}
|
||||
|
||||
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" || len(v) > maxLen {
|
||||
return ""
|
||||
}
|
||||
for _, r := range v {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package envvars
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "agent\x01injected")
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||
t.Setenv(CliAgentName, longVal)
|
||||
if got := AgentName(); got != "" {
|
||||
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||
if got := AgentTrace(); got != "trace-abc-123" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||
if got := AgentTrace(); got != "trace-trim" {
|
||||
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, " ")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||
t.Setenv(CliAgentTrace, longVal)
|
||||
if got := AgentTrace(); got != "" {
|
||||
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||
val := strings.Repeat("a", agentTraceMaxLen)
|
||||
t.Setenv(CliAgentTrace, val)
|
||||
if got := AgentTrace(); got != val {
|
||||
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||
// coupling this contract to core's brand enum.
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
// QueryEscape both values — clientID and scopes both sit in the query
|
||||
// string, and untrusted content must not be able to inject extra query
|
||||
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||
// open-platform base URL stays a single source of truth.
|
||||
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
|
||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
wantInURL: []string{"scopes=scope%23fragment"},
|
||||
denyInURL: []string{"scopes=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||
denyInURL: []string{"clientID=good?q=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
wantInURL: []string{"clientID=good%23fragment"},
|
||||
denyInURL: []string{"clientID=good#fragment"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
name: "slash in appID does not open a new path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||
if !strings.Contains(got, "developer console") {
|
||||
|
||||
@@ -10,15 +10,20 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
// Secure label endpoint codes observed from drive +secure-label-update
|
||||
// failure telemetry.
|
||||
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
|
||||
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -78,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
if isPlaceholderCredentialURL(file, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
if !warnForPrivateIPv4(file) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
value = strings.TrimPrefix(value, "$")
|
||||
value = strings.Trim(value, "%")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func highEntropyCredentialValue(value string) bool {
|
||||
if len(value) < 32 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-' || r == '.' || r == '=':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
||||
}
|
||||
|
||||
func shannonEntropy(value string) float64 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
counts := map[rune]int{}
|
||||
for _, r := range value {
|
||||
counts[r]++
|
||||
}
|
||||
var entropy float64
|
||||
length := float64(len([]rune(value)))
|
||||
for _, count := range counts {
|
||||
p := float64(count) / length
|
||||
entropy -= p * log2(p)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
func log2(value float64) float64 {
|
||||
return math.Log(value) / math.Ln2
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"api_token",
|
||||
"bot_token",
|
||||
"refresh_token",
|
||||
"secret_token",
|
||||
"session_token",
|
||||
"service_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
return credentialURLPasswordPlaceholder(password) ||
|
||||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
"pw",
|
||||
"s3cret",
|
||||
"secret",
|
||||
"t":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceOrTestFixtureFile(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
return sourceCodeFile(normalized) ||
|
||||
strings.HasPrefix(normalized, "testdata/") ||
|
||||
strings.HasPrefix(normalized, "fixtures/") ||
|
||||
strings.Contains(normalized, "/testdata/") ||
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func warnForPrivateIPv4(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
if sourceOrTestFixtureFile(normalized) {
|
||||
return false
|
||||
}
|
||||
switch filepath.Ext(normalized) {
|
||||
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(normalized, "docs/") ||
|
||||
strings.HasPrefix(normalized, "skills/")
|
||||
}
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
|
||||
@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
||||
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
||||
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
||||
`host := "192.168.0.10"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||
if len(benign) != 0 {
|
||||
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
||||
`proxy: http://user:pass@proxy:8080`,
|
||||
`repo: https://u:t@h/r.git`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_credential_url" {
|
||||
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
||||
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
||||
`endpoint: http://10.0.0.1:8080`,
|
||||
`redis: 192.168.1.10:6379`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_private_ipv4" {
|
||||
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||
for _, item := range got {
|
||||
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||
"URL=https://<user>:real-secret@example.invalid/path",
|
||||
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
||||
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
||||
`{"token":"img_abc123"}`,
|
||||
`{"token":"img_live_secret"}`,
|
||||
`{"token":"img_prod_key"}`,
|
||||
`token=ab********cd`,
|
||||
`{"image_token":"img_live_secret"}`,
|
||||
`{"data_mail_token":"mail_abc123"}`,
|
||||
`{"whiteboard_token":"board_v3_example"}`,
|
||||
`{"want_token":"token from callback"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
stripeToken := "sk_" + "live_1234567890abcdef"
|
||||
randomToken := strings.Join([]string{
|
||||
"a1b2c3d4",
|
||||
"e5f6g7h8",
|
||||
"i9j0k1l2",
|
||||
"m3n4p5q6",
|
||||
}, "")
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"token":"` + githubToken + `"}`,
|
||||
`token=` + stripeToken,
|
||||
`{"image_token":"` + githubToken + `"}`,
|
||||
`{"token":"` + randomToken + `"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
@@ -1052,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
core.ResolveOpenBaseURL(brand),
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallPnpm
|
||||
InstallManual
|
||||
)
|
||||
|
||||
@@ -53,22 +54,32 @@ var (
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
PnpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
switch d.Method {
|
||||
case InstallNpm:
|
||||
return d.NpmAvailable
|
||||
case InstallPnpm:
|
||||
return d.PnpmAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
switch {
|
||||
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||
return "installed via pnpm, but pnpm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm"
|
||||
return "not installed via npm or pnpm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
@@ -101,17 +113,38 @@ type Updater struct {
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
|
||||
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||
// binary was installed cannot change during a single process, so caching is
|
||||
// the correct semantics — and it is required for correctness: the update
|
||||
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||
// skills, so a re-detection at skills time could resolve a now-stale
|
||||
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||
// sequential.
|
||||
detectCache *DetectResult
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
if u.detectCache != nil {
|
||||
return *u.detectCache
|
||||
}
|
||||
result := u.detectInstallMethod()
|
||||
u.detectCache = &result
|
||||
return result
|
||||
}
|
||||
|
||||
// detectInstallMethod performs the real (uncached) detection.
|
||||
func (u *Updater) detectInstallMethod() DetectResult {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
_, npmErr := exec.LookPath("npm")
|
||||
_, pnpmErr := exec.LookPath("pnpm")
|
||||
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||
}
|
||||
|
||||
// detectFromResolved classifies the resolved binary path into an install
|
||||
// method and records package-manager availability. Split out from
|
||||
// DetectInstallMethod so the classification is unit-testable without touching
|
||||
// the filesystem or PATH.
|
||||
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
if containsPnpmMarker(resolved) {
|
||||
method = InstallPnpm
|
||||
} else {
|
||||
method = InstallNpm
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||
switch method {
|
||||
case InstallNpm:
|
||||
d.NpmAvailable = npmOnPath
|
||||
case InstallPnpm:
|
||||
d.PnpmAvailable = pnpmOnPath
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||
// whose resolved path runs through pnpm's home directory (e.g.
|
||||
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||
// followed by "store". Matching only these two shapes (rather than any bare
|
||||
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||
// classification is OS-independent and unit-testable anywhere.
|
||||
func containsPnpmMarker(p string) bool {
|
||||
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == ".pnpm" {
|
||||
return true
|
||||
}
|
||||
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||
if u.PnpmInstallOverride != nil {
|
||||
return u.PnpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
pnpmPath, err := exec.LookPath("pnpm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
||||
return u.runSkillsCommand(args...)
|
||||
}
|
||||
|
||||
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||
// launcher selection is unit-testable on any platform.
|
||||
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||
if method == InstallPnpm && pnpmAvailable {
|
||||
r := args
|
||||
if len(r) > 0 && r[0] == "-y" {
|
||||
r = r[1:]
|
||||
}
|
||||
return "pnpm", append([]string{"dlx"}, r...)
|
||||
}
|
||||
return "npx", args
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
det := u.DetectInstallMethod()
|
||||
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||
binPath, err := exec.LookPath(launcher)
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPnpmMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Classic virtual-store layout (.pnpm segment).
|
||||
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/usr/local/bin/lark-cli", false},
|
||||
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||
// "pnpm" immediately followed by "store".
|
||||
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsPnpmMarker(c.path); got != c.want {
|
||||
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||
u := &Updater{DetectOverride: nil}
|
||||
u.DetectOverride = func() DetectResult {
|
||||
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||
}
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm {
|
||||
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||
}
|
||||
if !got.PnpmAvailable {
|
||||
t.Errorf("PnpmAvailable = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||
}
|
||||
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||
t.Error("pnpm available should CanAutoUpdate")
|
||||
}
|
||||
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualReason_Pnpm(t *testing.T) {
|
||||
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||
t.Errorf("pnpm reason = %q", got)
|
||||
}
|
||||
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||
t.Errorf("manual reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||
return r
|
||||
}}
|
||||
got := u.RunPnpmInstall("2.0.0")
|
||||
if got.Err != nil {
|
||||
t.Fatalf("unexpected err: %v", got.Err)
|
||||
}
|
||||
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsInvocation(t *testing.T) {
|
||||
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||
cases := []struct {
|
||||
name string
|
||||
method InstallMethod
|
||||
pnpmAvailable bool
|
||||
args []string
|
||||
wantLauncher string
|
||||
wantRest []string
|
||||
}{
|
||||
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||
"npx", addArgs},
|
||||
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||
if gotLauncher != c.wantLauncher {
|
||||
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||
}
|
||||
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||
// stays consistent with what was detected — and reported — before the update.
|
||||
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
u := New()
|
||||
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||
u.detectCache = &cached
|
||||
got := u.DetectInstallMethod()
|
||||
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 5 * time.Second
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.63",
|
||||
"version": "1.0.66",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
return attendees, nil
|
||||
}
|
||||
|
||||
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||
for _, attendee := range attendees {
|
||||
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
||||
if err == nil || !attendeesIncludeRoom(attendees) {
|
||||
return err
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
||||
return err
|
||||
}
|
||||
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
||||
}
|
||||
|
||||
var CalendarCreate = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+create",
|
||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
||||
"need_notification": true,
|
||||
})
|
||||
if err != nil {
|
||||
err = guideApprovalRoomReasonError(err, attendees)
|
||||
// Rollback: delete the event
|
||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
|
||||
)
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("content") != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCreateBody(runtime)
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
desc := "OpenAPI: create document"
|
||||
if runtime.IsBot() {
|
||||
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
|
||||
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
}
|
||||
|
||||
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildCreateBody(runtime)
|
||||
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
|
||||
if err != nil {
|
||||
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
content := runtime.Str("content")
|
||||
return buildCreateContentWithBody(runtime, runtime.Str("content"))
|
||||
}
|
||||
|
||||
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
|
||||
return err
|
||||
}
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
|
||||
@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
if got["return_html5_block_data"] != true {
|
||||
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdownFence",
|
||||
"revision_id": float64(1),
|
||||
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdownFence",
|
||||
"--doc-format", "im-markdown",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if errField, ok := envelope["error"]; ok {
|
||||
t.Fatalf("fetch output should not contain error: %#v", errField)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
|
||||
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
|
||||
}
|
||||
if _, ok := doc["reference_map"]; ok {
|
||||
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsHistoryListSpec struct {
|
||||
Doc documentRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type docsHistoryRevertSpec struct {
|
||||
Doc documentRef
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type docsHistoryRevertStatusSpec struct {
|
||||
Doc documentRef
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||
ref, err := parseDocumentRef(raw)
|
||||
if err != nil {
|
||||
return documentRef{}, err
|
||||
}
|
||||
if ref.Kind == "doc" {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||
}
|
||||
|
||||
var DocsHistoryList = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-list",
|
||||
Description: "List Lark document history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-list"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: list document history versions").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryListParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||
docsHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Lark document to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: revert document history").
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Body(docsHistoryRevertBody(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||
nil,
|
||||
docsHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevertStatus = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Lark document history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: get document history revert status").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryStatusParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||
docsHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
340
shortcuts/doc/docs_history_test.go
Normal file
340
shortcuts/doc/docs_history_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDocsHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects legacy doc URL",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||
param: "--doc",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: DocsHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != tt.category {
|
||||
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||
}
|
||||
if problem.Subtype != tt.subtype {
|
||||
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||
"+history-list",
|
||||
"--doc", "doxcnHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--doc", "doxcnHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": []interface{}{"blk_1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--doc", "doxcnHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "partial_failed" {
|
||||
t.Fatalf("status = %#v, want partial_failed", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -63,6 +64,39 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"content": `<html5-block path="@missing.html"></html5-block>`,
|
||||
})
|
||||
|
||||
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "path" {
|
||||
t.Fatalf("param = %q, want path", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
|
||||
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
@@ -115,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
|
||||
}
|
||||
}
|
||||
if content != "" {
|
||||
_, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
@@ -134,7 +143,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body, err := buildUpdateBodyWithReferenceMap(runtime)
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
696
shortcuts/doc/html5_block_resources.go
Normal file
696
shortcuts/doc/html5_block_resources.go
Normal file
@@ -0,0 +1,696 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
html5BlockTag = "html5-block"
|
||||
html5BlockPathAttr = "path"
|
||||
html5BlockDataRefAttr = "data-ref"
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
Data string `json:"data,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
|
||||
|
||||
type docsV2WriteInput struct {
|
||||
Content string
|
||||
ReferenceMap map[string]interface{}
|
||||
}
|
||||
|
||||
type html5BlockAttr struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type html5BlockStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
}
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["content"] = buildCreateContentWithBody(runtime, input.Content)
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildUpdateBody(runtime)
|
||||
input, err := resolveDocsV2ContentReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Content != "" {
|
||||
body["content"] = input.Content
|
||||
}
|
||||
if len(input.ReferenceMap) > 0 {
|
||||
body["reference_map"] = input.ReferenceMap
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
|
||||
input := docsV2WriteInput{Content: runtime.Str("content")}
|
||||
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
|
||||
refMap, err := parseReferenceMapObject(raw, "--reference-map")
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
input.ReferenceMap = refMap
|
||||
}
|
||||
return prepareDocsV2WriteInput(runtime, input)
|
||||
}
|
||||
|
||||
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
|
||||
refMap := cloneReferenceMapObject(input.ReferenceMap)
|
||||
html5RefMap, err := html5ReferenceMapFromObject(refMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
|
||||
return docsV2WriteInput{
|
||||
Content: content,
|
||||
ReferenceMap: refMap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
|
||||
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var refMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var refMap html5BlockReferenceMap
|
||||
if err := json.Unmarshal(raw, &refMap); err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
|
||||
}
|
||||
return compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
|
||||
if !strings.Contains(content, "<html5-block") {
|
||||
return content, compactReferenceMap(refMap), nil
|
||||
}
|
||||
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
refMap = cloneReferenceMap(refMap)
|
||||
if refMap == nil {
|
||||
refMap = html5BlockReferenceMap{}
|
||||
}
|
||||
ensureReferenceGroup(refMap, html5BlockTag)
|
||||
nextRef := nextHTML5BlockRef(refMap)
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
|
||||
tag, err := parseHTML5BlockStartTag(raw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
|
||||
}
|
||||
if tag.hasAttr(html5BlockDataAttr) {
|
||||
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(html5BlockPathAttr)
|
||||
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
|
||||
if hasPath && hasDataRef {
|
||||
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
|
||||
}
|
||||
if hasDataRef {
|
||||
ref := strings.TrimSpace(dataRef)
|
||||
if ref == "" {
|
||||
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
|
||||
}
|
||||
if _, ok := refMap[html5BlockTag][ref]; !ok {
|
||||
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
|
||||
}
|
||||
return tag.render(false), nil
|
||||
}
|
||||
if !hasPath {
|
||||
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
|
||||
}
|
||||
|
||||
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ref := nextRef()
|
||||
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
|
||||
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
|
||||
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
|
||||
return tag.render(false), nil
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
out string
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(format) == "markdown" {
|
||||
out = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if err != nil {
|
||||
return segment
|
||||
}
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
err = rewriteErr
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
} else {
|
||||
out, err = rewrite(content)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
|
||||
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return validateSegment(content)
|
||||
}
|
||||
|
||||
var validateErr error
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if validateErr != nil {
|
||||
return segment
|
||||
}
|
||||
validateErr = validateSegment(segment)
|
||||
return segment
|
||||
})
|
||||
return validateErr
|
||||
}
|
||||
|
||||
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
content, _ := doc["content"].(string)
|
||||
if !hasProcessableHTML5Block(format, content) {
|
||||
return nil
|
||||
}
|
||||
|
||||
refMap, err := referenceMapFromDocument(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group := refMap[html5BlockTag]
|
||||
if group == nil {
|
||||
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
|
||||
}
|
||||
|
||||
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for ref, entry := range group {
|
||||
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
|
||||
continue
|
||||
}
|
||||
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Data = ""
|
||||
entry.Path = "@" + filepath.ToSlash(relPath)
|
||||
group[ref] = entry
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
doc["reference_map"] = refMap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
|
||||
raw, ok := doc["reference_map"]
|
||||
if !ok || raw == nil {
|
||||
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
|
||||
}
|
||||
refMap, err := referenceMapFromValue(raw, "document.reference_map")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(refMap) == 0 {
|
||||
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
|
||||
if typed, ok := value.(html5BlockReferenceMap); ok {
|
||||
return compactReferenceMap(typed), nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
|
||||
}
|
||||
return parseHTML5BlockReferenceMapBytes(raw, label)
|
||||
}
|
||||
|
||||
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
|
||||
validateSegment := func(segment string) error {
|
||||
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
|
||||
tag, parseErr := parseHTML5BlockStartTag(raw)
|
||||
if parseErr != nil {
|
||||
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
|
||||
}
|
||||
ref, ok := tag.attr(html5BlockDataRefAttr)
|
||||
if !ok || strings.TrimSpace(ref) == "" {
|
||||
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
|
||||
}
|
||||
ref = strings.TrimSpace(ref)
|
||||
if _, ok := refMap[html5BlockTag][ref]; !ok {
|
||||
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
|
||||
}
|
||||
return raw, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return validateSegment(content)
|
||||
}
|
||||
var validateErr error
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if validateErr != nil {
|
||||
return segment
|
||||
}
|
||||
validateErr = validateSegment(segment)
|
||||
return segment
|
||||
})
|
||||
return validateErr
|
||||
}
|
||||
|
||||
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
|
||||
for typ, group := range refMap {
|
||||
for ref, entry := range group {
|
||||
if strings.TrimSpace(entry.Path) == "" {
|
||||
continue
|
||||
}
|
||||
if entry.Data != "" {
|
||||
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
|
||||
}
|
||||
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Data = data
|
||||
entry.Path = ""
|
||||
group[ref] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(clean)) != ".html" {
|
||||
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func hasProcessableHTML5Block(format string, content string) bool {
|
||||
if !strings.Contains(content, "<html5-block") {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
_ = applyOutsideCodeFences(content, func(segment string) string {
|
||||
if strings.Contains(segment, "<html5-block") {
|
||||
found = true
|
||||
}
|
||||
return segment
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
|
||||
var out strings.Builder
|
||||
var segment strings.Builder
|
||||
inFence := false
|
||||
|
||||
flush := func() {
|
||||
if segment.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out.WriteString(fn(segment.String()))
|
||||
segment.Reset()
|
||||
}
|
||||
|
||||
for _, line := range strings.SplitAfter(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
if !inFence {
|
||||
flush()
|
||||
inFence = true
|
||||
} else {
|
||||
inFence = false
|
||||
}
|
||||
out.WriteString(line)
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
out.WriteString(line)
|
||||
} else {
|
||||
segment.WriteString(line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(html5BlockReferenceMap, len(refMap))
|
||||
for typ, group := range refMap {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
|
||||
for ref, entry := range group {
|
||||
outGroup[ref] = entry
|
||||
}
|
||||
out[typ] = outGroup
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(refMap))
|
||||
for key, value := range refMap {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
|
||||
if len(refMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
group, ok := refMap[html5BlockTag]
|
||||
if !ok || group == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
|
||||
}
|
||||
|
||||
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
|
||||
group := html5RefMap[html5BlockTag]
|
||||
if len(group) == 0 {
|
||||
return refMap
|
||||
}
|
||||
if refMap == nil {
|
||||
refMap = map[string]interface{}{}
|
||||
}
|
||||
refMap[html5BlockTag] = group
|
||||
return refMap
|
||||
}
|
||||
|
||||
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
|
||||
if len(refMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(html5BlockReferenceMap, len(refMap))
|
||||
for typ, group := range refMap {
|
||||
if len(group) == 0 {
|
||||
continue
|
||||
}
|
||||
out[typ] = group
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
|
||||
if refMap[typ] == nil {
|
||||
refMap[typ] = map[string]html5BlockReferenceEntry{}
|
||||
}
|
||||
}
|
||||
|
||||
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
|
||||
next := 1
|
||||
return func() string {
|
||||
for {
|
||||
ref := fmt.Sprintf("html5_%d", next)
|
||||
next++
|
||||
if _, exists := refMap[html5BlockTag][ref]; !exists {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
|
||||
if !isSafeHTML5BlockResourceName(docToken) {
|
||||
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
|
||||
}
|
||||
if !isSafeHTML5BlockResourceName(ref) {
|
||||
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
|
||||
}
|
||||
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
|
||||
data := []byte(html)
|
||||
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
|
||||
ContentType: "text/html; charset=utf-8",
|
||||
ContentLength: int64(len(data)),
|
||||
}, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
|
||||
}
|
||||
return relPath, nil
|
||||
}
|
||||
|
||||
func isSafeHTML5BlockResourceName(name string) bool {
|
||||
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
|
||||
var rewriteErr error
|
||||
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
if rewriteErr != nil {
|
||||
return raw
|
||||
}
|
||||
rewritten, err := fn(raw)
|
||||
if err != nil {
|
||||
rewriteErr = err
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return "", rewriteErr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, 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 html5BlockStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != html5BlockTag {
|
||||
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, 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 html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (t *html5BlockStartTag) 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 html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(html5BlockTag)
|
||||
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('>')
|
||||
}
|
||||
if t.SelfClosing && !selfClosing {
|
||||
b.WriteString("</")
|
||||
b.WriteString(html5BlockTag)
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
case '"':
|
||||
b.WriteString(""")
|
||||
case '\'':
|
||||
b.WriteString("'")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
563
shortcuts/doc/html5_block_resources_test.go
Normal file
563
shortcuts/doc/html5_block_resources_test.go
Normal file
@@ -0,0 +1,563 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
flag := findDocsTestFlag(flags, "reference-map")
|
||||
if flag.Name == "" {
|
||||
t.Fatal("reference-map flag not found")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("reference-map flag should be public")
|
||||
}
|
||||
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "@reference-map.json") {
|
||||
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
|
||||
for name, flags := range map[string][]common.Flag{
|
||||
"create": v2CreateFlags(),
|
||||
"update": v2UpdateFlags(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "input" {
|
||||
t.Fatalf("%s should not expose input flag", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"command": "append",
|
||||
"content": `<p><widget data-ref="r1"></widget></p>`,
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
})
|
||||
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
|
||||
}
|
||||
|
||||
refMap, ok := body["reference_map"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
|
||||
}
|
||||
widget, _ := refMap["widget"].(map[string]interface{})
|
||||
r1, _ := widget["r1"].(map[string]interface{})
|
||||
if got := r1["label"]; got != "widget-ref-value" {
|
||||
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", 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", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content was not rewritten with data-ref: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
if _, ok := body["resources"]; ok {
|
||||
t.Fatalf("request body must not use resources: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func hasDocsTestInput(flag common.Flag, input string) bool {
|
||||
for _, item := range flag.Input {
|
||||
if item == input {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
|
||||
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"revision_id": float64(2),
|
||||
"new_blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": "html5-block",
|
||||
"block_id": "blk_html5",
|
||||
"block_token": "boardXXXX",
|
||||
},
|
||||
},
|
||||
},
|
||||
"result": "success",
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsUpdate, []string{
|
||||
"+update",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_doc",
|
||||
"--command", "append",
|
||||
"--content", `<html5-block path="@widget.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
|
||||
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tips": "must_read_html_code",
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
|
||||
if _, err := os.Stat(written); err == nil {
|
||||
t.Fatalf("small html should stay inline, got file %s", written)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content should keep data-ref: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
if _, ok := doc["resources"]; ok {
|
||||
t.Fatalf("fetch output must not use resources: %#v", doc)
|
||||
}
|
||||
if _, ok := data["suggestions"]; ok {
|
||||
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
|
||||
}
|
||||
if got := data["tips"]; got != "must_read_html_code" {
|
||||
t.Fatalf("tips should be preserved from service response, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": largeHTML},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
|
||||
raw, err := os.ReadFile(written)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error: %v", written, err)
|
||||
}
|
||||
if string(raw) != largeHTML {
|
||||
t.Fatalf("materialized html = %q", raw)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
|
||||
t.Fatalf("content should keep data-ref and not path: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
|
||||
entry := refMap[html5BlockTag]["html5_1"]
|
||||
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
|
||||
t.Fatalf("large html should be represented as path, got %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
|
||||
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", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(reference-map.json) error: %v", 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", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--reference-map", "@reference-map.json",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
|
||||
t.Fatalf("expected missing reference_map error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
|
||||
t.Fatalf("expected internal data attr error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockPathReadFailure(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", `<html5-block path="@missing.html"></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
|
||||
t.Fatalf("expected path read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
|
||||
t.Fatalf("expected inline content error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
|
||||
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_fetch",
|
||||
"revision_id": float64(3),
|
||||
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
|
||||
"reference_map": map[string]interface{}{
|
||||
"html5-block": map[string]interface{}{
|
||||
"html5_1": map[string]interface{}{"data": "<html></html>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--api-version", "v2",
|
||||
"--doc", "doxcn_fetch",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
|
||||
t.Fatalf("expected missing reference_map error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
|
||||
for _, fence := range []string{"```", "~~~"} {
|
||||
t.Run(fence, func(t *testing.T) {
|
||||
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
|
||||
if hasProcessableHTML5Block("markdown", content) {
|
||||
t.Fatalf("html5-block inside markdown code fence should be ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
|
||||
runtime := newFetchShortcutTestRuntime(t, "", nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
docToken string
|
||||
ref string
|
||||
want string
|
||||
}{
|
||||
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
|
||||
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
|
||||
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
|
||||
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", 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",
|
||||
"--doc-format", "markdown",
|
||||
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
|
||||
t.Fatalf("content was not rewritten: %s", got)
|
||||
}
|
||||
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
|
||||
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
|
||||
t.Fatalf("reference_map html data = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: method,
|
||||
URL: url,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
|
||||
t.Fatalf("decode request body: %v\n%s", err, raw)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
|
||||
}
|
||||
var refMap html5BlockReferenceMap
|
||||
if err := json.Unmarshal(data, &refMap); err != nil {
|
||||
t.Fatalf("decode reference_map: %v\n%s", err, data)
|
||||
}
|
||||
return refMap
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
case "history-list":
|
||||
return "lark-cli docs +history-list --help"
|
||||
case "history-revert":
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
DocMediaInsert,
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +29,8 @@ const (
|
||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
|
||||
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
|
||||
)
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"pptx": {"slides"},
|
||||
}
|
||||
|
||||
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
type driveImportSpec struct {
|
||||
FilePath string
|
||||
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
return status, false, driveImportFailureError(status)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
func driveImportFailureError(status driveImportStatus) *errs.APIError {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
|
||||
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
if code, ok := driveImportConcurrentOperationCode(msg); ok {
|
||||
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
func driveImportConcurrentOperationCode(msg string) (int, bool) {
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
codeText := strconv.Itoa(code)
|
||||
for idx := strings.Index(msg, codeText); idx >= 0; {
|
||||
end := idx + len(codeText)
|
||||
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
|
||||
return code, true
|
||||
}
|
||||
|
||||
nextStart := idx + 1
|
||||
next := strings.Index(msg[nextStart:], codeText)
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
idx = nextStart + next
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isASCIIDigit(ch byte) bool {
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, code := range driveImportConcurrentOperationCodes {
|
||||
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
|
||||
}
|
||||
if problem.Code != code {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, code)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if problem.Hint != driveImportConcurrentOperationHint {
|
||||
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
msg string
|
||||
}{
|
||||
{
|
||||
name: "ordinary failure",
|
||||
msg: "unsupported conversion",
|
||||
},
|
||||
{
|
||||
name: "longer numeric code containing known code",
|
||||
msg: "call CreateObjNode return error code, code: 12321401012, message:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := driveImportFailureError(driveImportStatus{
|
||||
JobStatus: 3,
|
||||
JobErrorMsg: tt.msg,
|
||||
})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Code != 0 {
|
||||
t.Fatalf("code = %d, want 0", problem.Code)
|
||||
}
|
||||
if problem.Retryable {
|
||||
t.Fatal("expected non-concurrency failure to remain non-retryable")
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", problem.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -17,6 +18,13 @@ const (
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
type secureLabelOperation string
|
||||
|
||||
const (
|
||||
secureLabelOperationList secureLabelOperation = "list"
|
||||
secureLabelOperationUpdate secureLabelOperation = "update"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
@@ -28,6 +36,9 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
Scopes: []string{secureLabelReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
@@ -53,7 +64,7 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return decorateSecureLabelError(err, secureLabelOperationList)
|
||||
}
|
||||
runtime.OutFormat(data, nil, nil)
|
||||
return nil
|
||||
@@ -68,13 +79,21 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{secureLabelUpdateScope},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
|
||||
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
|
||||
"When updating many files, serialize requests and back off on rate_limit errors.",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
|
||||
{Name: "label-id", Desc: "secure label ID to set", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -82,11 +101,15 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive secure label").
|
||||
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
|
||||
Body(map[string]interface{}{"id": labelID}).
|
||||
Set("file_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -94,14 +117,18 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": labelID}
|
||||
data, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return decorateSecureLabelError(err, secureLabelOperationUpdate)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
@@ -122,3 +149,70 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
}
|
||||
|
||||
// normalizeSecureLabelID trims a label id and rejects display names before the
|
||||
// request reaches Drive, where they otherwise surface as opaque JSON errors.
|
||||
func normalizeSecureLabelID(raw string) (string, error) {
|
||||
labelID := strings.TrimSpace(raw)
|
||||
if labelID == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
|
||||
WithParam("--label-id")
|
||||
}
|
||||
for _, r := range labelID {
|
||||
if r < '0' || r > '9' {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
|
||||
WithParam("--label-id").
|
||||
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
|
||||
}
|
||||
}
|
||||
return labelID, nil
|
||||
}
|
||||
|
||||
// decorateSecureLabelError appends command-aware recovery guidance while
|
||||
// preserving upstream/classifier hints already attached to the typed error.
|
||||
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
guidance := secureLabelErrorGuidance(p.Code, operation)
|
||||
if guidance == "" {
|
||||
return err
|
||||
}
|
||||
if p.Hint == "" {
|
||||
p.Hint = guidance
|
||||
} else if !strings.Contains(p.Hint, guidance) {
|
||||
p.Hint = p.Hint + "; " + guidance
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// secureLabelErrorGuidance returns recovery guidance for secure-label API
|
||||
// failures whose generic code-level classification needs command context.
|
||||
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
|
||||
switch code {
|
||||
case 99991400:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
|
||||
}
|
||||
return "secure label listing is rate limited; retry later with exponential backoff"
|
||||
case 1063013:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
|
||||
}
|
||||
case 1063002:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
|
||||
}
|
||||
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
|
||||
case 1063001, 99992402, 9499:
|
||||
if operation == secureLabelOperationUpdate {
|
||||
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
|
||||
}
|
||||
return "check secure label list parameters such as --page-size, --page-token, and --lang"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -90,13 +92,54 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "rate limit exceeded",
|
||||
"error": map[string]interface{}{
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server says slow down"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
|
||||
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
|
||||
}
|
||||
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
|
||||
if !strings.Contains(apiErr.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
|
||||
}
|
||||
}
|
||||
if strings.Contains(apiErr.Hint, "updates are rate limited") {
|
||||
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--label-id", " 7217780879644737539 ",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -132,7 +175,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--label-id", " 7217780879644737539 ",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -148,7 +191,32 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "Public(D)",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected label id validation error")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--label-id" {
|
||||
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
|
||||
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
@@ -169,7 +237,78 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected 1063013 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
|
||||
t.Fatalf("expected raw API error message, got: %v", err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
|
||||
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "approval") {
|
||||
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 9499, "msg": "Invalid parameter type in json: id",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 9499 error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
|
||||
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
|
||||
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 429,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400, "msg": "rate limit exceeded",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
|
||||
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, "backoff") {
|
||||
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
|
||||
want := []string{
|
||||
"+chat-create",
|
||||
"+chat-list",
|
||||
"+chat-members-list",
|
||||
"+chat-messages-list",
|
||||
"+chat-search",
|
||||
"+chat-update",
|
||||
|
||||
420
shortcuts/im/im_chat_members_list.go
Normal file
420
shortcuts/im/im_chat_members_list.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||
chatMembersListDefaultPageSize = 20
|
||||
chatMembersListMaxPageSize = 100
|
||||
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||
// server-side member cap, where a large group drains many pages back to
|
||||
// back and could otherwise trip rate limits.
|
||||
chatMembersListDefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
|
||||
// returning users and bots in separate buckets (users[]/bots[]). It owns its
|
||||
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
|
||||
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
|
||||
// the response is multi-bucket — the generic --page-all merger is built for
|
||||
// single-array responses and would drop the bots[] bucket and the final-page
|
||||
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
|
||||
var ImChatMembersList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-members-list",
|
||||
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
|
||||
Risk: "read",
|
||||
// Declare the narrowest scope the API accepts so tokens carrying only
|
||||
// im:chat.members:read are honored (same rationale as +chat-list).
|
||||
Scopes: []string{"im:chat.members:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Default fetches a single page; pass --page-all to walk every page.",
|
||||
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
|
||||
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
if chatID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if !strings.HasPrefix(chatID, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
if n := runtime.Int("page-delay"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
|
||||
}
|
||||
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
dry := common.NewDryRunAPI()
|
||||
if chatMembersShouldAutoPaginate(runtime) {
|
||||
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||
}
|
||||
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
return dry.
|
||||
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
|
||||
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||
res, err := fetchChatMembers(ctx, runtime, chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The truncation signal is the whole reason this is a dedicated shortcut:
|
||||
// surface it loudly so an agent never mistakes a capped list for a
|
||||
// complete one.
|
||||
if len(res.truncations) > 0 {
|
||||
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"users": res.users,
|
||||
"bots": res.bots,
|
||||
"truncations": res.truncations,
|
||||
"has_more": res.hasMore,
|
||||
"page_token": res.pageToken,
|
||||
}
|
||||
if res.userTotal != nil {
|
||||
outData["user_total"] = res.userTotal
|
||||
}
|
||||
if res.botTotal != nil {
|
||||
outData["bot_total"] = res.botTotal
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
|
||||
renderChatMembersPretty(w, chatID, res)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// chatMembersResult is the aggregated view across one or more pages.
|
||||
type chatMembersResult struct {
|
||||
users []interface{}
|
||||
bots []interface{}
|
||||
truncations []interface{}
|
||||
userTotal interface{}
|
||||
botTotal interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
// effectiveChatMembersPageSize resolves the page_size to request. When draining
|
||||
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
|
||||
// uses the maximum so a full walk takes the fewest round-trips. An explicit
|
||||
// --page-size is always honored; without --page-all the smaller default is kept
|
||||
// as a sensible single-page preview size.
|
||||
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
|
||||
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
|
||||
return chatMembersListMaxPageSize
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
return n
|
||||
}
|
||||
return chatMembersListDefaultPageSize
|
||||
}
|
||||
|
||||
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
|
||||
// every page. An explicit --page-token disables the auto loop because the
|
||||
// caller supplied a specific cursor (single-page fetch).
|
||||
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" {
|
||||
return false
|
||||
}
|
||||
return runtime.Bool("page-all")
|
||||
}
|
||||
|
||||
// buildChatMembersParams builds the query params for one page request. The
|
||||
// startToken (when non-empty) seeds the page_token; the loop overrides it per
|
||||
// page. Returns the params and the normalized member-types CSV (already
|
||||
// validated by Validate, so the error is only a defensive guard).
|
||||
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
|
||||
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"member_id_type": runtime.Str("member-id-type"),
|
||||
"page_size": effectiveChatMembersPageSize(runtime),
|
||||
}
|
||||
if memberTypes != "" {
|
||||
params["member_types"] = memberTypes
|
||||
}
|
||||
if startToken != "" {
|
||||
params["page_token"] = startToken
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// fetchChatMembers walks the list_members endpoint, honoring the four
|
||||
// pagination flags the same way the generic --page-all path does. It merges
|
||||
// each page into the aggregate as it arrives (rather than buffering every raw
|
||||
// page), so peak memory is just the aggregated members plus the single most
|
||||
// recent page — important for large groups under --page-limit 0.
|
||||
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
|
||||
auto := chatMembersShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
pageDelay := runtime.Int("page-delay")
|
||||
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
|
||||
|
||||
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := newChatMembersResult()
|
||||
var lastData map[string]interface{}
|
||||
pageToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
for page := 0; ; page++ {
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addMemberBuckets(res, data)
|
||||
lastData = data
|
||||
|
||||
hasMore, nextToken := common.PaginationMeta(data)
|
||||
if !auto {
|
||||
break
|
||||
}
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
if nextToken == pageToken {
|
||||
// Guard against a buggy server echoing the same cursor with
|
||||
// has_more=true: without --page-limit we would loop forever.
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
|
||||
break
|
||||
}
|
||||
if pageLimit > 0 && page+1 >= pageLimit {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
// Throttle between pages (only reached when another page follows), so
|
||||
// draining a large untruncated list doesn't hammer the API.
|
||||
if pageDelay > 0 {
|
||||
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if lastData != nil {
|
||||
applyLastPageSignals(res, lastData)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
|
||||
// JSON output always carries arrays (never null).
|
||||
func newChatMembersResult() *chatMembersResult {
|
||||
return &chatMembersResult{
|
||||
users: []interface{}{},
|
||||
bots: []interface{}{},
|
||||
truncations: []interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
|
||||
// Concatenating every bucket is what avoids dropping bots[] — the bug the
|
||||
// generic single-array --page-all merger would hit on this multi-bucket shape.
|
||||
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
|
||||
if u, ok := data["users"].([]interface{}); ok {
|
||||
res.users = append(res.users, u...)
|
||||
}
|
||||
if b, ok := data["bots"].([]interface{}); ok {
|
||||
res.bots = append(res.bots, b...)
|
||||
}
|
||||
}
|
||||
|
||||
// applyLastPageSignals copies the per-request signals from the FINAL page:
|
||||
// has_more / page_token / truncations / totals. These must come from the last
|
||||
// page, not page 1: truncations[] is emitted only on the final page (empty
|
||||
// earlier), so reading it sooner would hide a server-side cap; user_total /
|
||||
// bot_total are server-wide counts, and taking the final page's value keeps a
|
||||
// single, consistent source rather than a possibly-stale earlier count.
|
||||
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
|
||||
res.hasMore, res.pageToken = common.PaginationMeta(data)
|
||||
if t, ok := data["truncations"].([]interface{}); ok {
|
||||
res.truncations = t
|
||||
}
|
||||
res.userTotal = data["user_total"]
|
||||
res.botTotal = data["bot_total"]
|
||||
}
|
||||
|
||||
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
|
||||
// the same logic fetchChatMembers applies incrementally, kept as a pure
|
||||
// function so the multi-bucket merge + last-page-signal semantics are unit
|
||||
// tested in one place.
|
||||
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
|
||||
res := newChatMembersResult()
|
||||
if len(pages) == 0 {
|
||||
return res
|
||||
}
|
||||
for _, data := range pages {
|
||||
addMemberBuckets(res, data)
|
||||
}
|
||||
applyLastPageSignals(res, pages[len(pages)-1])
|
||||
return res
|
||||
}
|
||||
|
||||
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
|
||||
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
|
||||
// the API's default of all types). Any element outside {user, bot} is rejected.
|
||||
func normalizeMemberTypes(raw []string) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p != "user" && p != "bot" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ","), nil
|
||||
}
|
||||
|
||||
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
|
||||
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
|
||||
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
|
||||
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
|
||||
}
|
||||
}
|
||||
|
||||
// writeChatMembersTruncationWarning emits a stderr warning for every
|
||||
// server-side bucket cap reported in truncations[]. It uses the repo's plain
|
||||
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
|
||||
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
|
||||
// pipes regardless of terminal encoding.
|
||||
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
|
||||
for _, t := range truncations {
|
||||
tm, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memberType := valueOrAll(tm["member_type"])
|
||||
limit := tm["limit"]
|
||||
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrAll(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
|
||||
fmt.Fprintf(w, "Chat: %s\n", chatID)
|
||||
// Show the server-wide total next to the fetched count: when truncated or
|
||||
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
|
||||
// what tells the reader how incomplete the list is.
|
||||
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
|
||||
for i, u := range res.users {
|
||||
m, _ := u.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
|
||||
for i, b := range res.bots {
|
||||
m, _ := b.(map[string]interface{})
|
||||
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||
}
|
||||
if len(res.truncations) > 0 {
|
||||
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
|
||||
}
|
||||
if res.hasMore {
|
||||
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
|
||||
if res.pageToken != "" {
|
||||
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDash(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// totalSuffix renders " of <total>" when the server-reported total exceeds the
|
||||
// number actually fetched (so a truncated/partial bucket is obvious), and ""
|
||||
// when the total is absent or already matches the fetched count.
|
||||
func totalSuffix(total interface{}, fetched int) string {
|
||||
n, ok := toInt(total)
|
||||
if !ok || n <= fetched {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" of %d", n)
|
||||
}
|
||||
|
||||
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
325
shortcuts/im/im_chat_members_list_test.go
Normal file
325
shortcuts/im/im_chat_members_list_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// page builds one list_members page payload shaped like the data object the
|
||||
// server returns (users[]/bots[]/truncations[] plus paging + totals).
|
||||
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"users": users,
|
||||
"bots": bots,
|
||||
"truncations": truncations,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
"user_total": 324,
|
||||
"bot_total": 2,
|
||||
}
|
||||
}
|
||||
|
||||
func us(ids ...string) []interface{} {
|
||||
out := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]interface{}{"member_id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
|
||||
// (users AND bots) must be concatenated across pages, not just one of them.
|
||||
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 merged, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 3 {
|
||||
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
|
||||
// is emitted only on the final page, so the merged view must take it from the
|
||||
// last page rather than inherit page 1's empty slice.
|
||||
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
|
||||
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
|
||||
cmlPage(us("u2"), nil, limit, false, ""),
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if len(res.truncations) != 1 {
|
||||
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
|
||||
// signals come from the final page (so a --page-limit cutoff is visible).
|
||||
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
cmlPage(us("u1"), nil, nil, true, "p2"),
|
||||
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
|
||||
}
|
||||
|
||||
res := mergeChatMemberPages(pages)
|
||||
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true from last page")
|
||||
}
|
||||
if res.pageToken != "p3" {
|
||||
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
|
||||
// are taken from the final page (not an earlier, possibly-different value).
|
||||
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
|
||||
pages := []map[string]interface{}{
|
||||
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
|
||||
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
|
||||
}
|
||||
res := mergeChatMemberPages(pages)
|
||||
if n, _ := toInt(res.userTotal); n != 324 {
|
||||
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
|
||||
}
|
||||
if n, _ := toInt(res.botTotal); n != 2 {
|
||||
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
|
||||
func TestChatMembersValidate(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
chatID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid oc_", "oc_abc", false},
|
||||
{"empty", "", true},
|
||||
{"missing oc_ prefix", "abc123", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
|
||||
err := ImChatMembersList.Validate(context.Background(), rt)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, c.name, err, "--chat-id")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationError checks err satisfies the repo's typed-error contract for
|
||||
// a validation failure: a *errs.ValidationError carrying the expected Param, and
|
||||
// problem metadata of category validation / subtype invalid_argument.
|
||||
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
|
||||
return
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMemberTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, "", false},
|
||||
{[]string{"user", "bot"}, "user,bot", false},
|
||||
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
|
||||
{[]string{"admin"}, "", true},
|
||||
{[]string{""}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := normalizeMemberTypes(c.in)
|
||||
if c.wantErr {
|
||||
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
|
||||
// drain with no explicit size → max; explicit size → honored; single page → default.
|
||||
func TestEffectiveChatMembersPageSize(t *testing.T) {
|
||||
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
b map[string]bool
|
||||
ints map[string]int
|
||||
want int
|
||||
}{
|
||||
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
|
||||
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
|
||||
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
|
||||
if got := effectiveChatMembersPageSize(rt); got != c.want {
|
||||
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newChatMembersTestRuntime registers the shortcut's flags and returns a
|
||||
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
|
||||
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
runtime := newUserShortcutRuntime(t, rt)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("chat-id", "", "")
|
||||
cmd.Flags().String("member-id-type", "open_id", "")
|
||||
cmd.Flags().StringSlice("member-types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
for k, v := range str {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range b {
|
||||
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for k, v := range ints {
|
||||
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
return runtime
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
|
||||
// fetch loop over mocked pages: users/bots merge across pages and the final
|
||||
// page's truncations[] survives.
|
||||
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
|
||||
calls := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
|
||||
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
|
||||
}
|
||||
calls++
|
||||
token := req.URL.Query().Get("page_token")
|
||||
if token == "" {
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("want 2 page calls, got %d", calls)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3, got %d", len(res.users))
|
||||
}
|
||||
if len(res.bots) != 2 {
|
||||
t.Errorf("bots: want 2, got %d", len(res.bots))
|
||||
}
|
||||
if len(res.truncations) != 1 {
|
||||
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
|
||||
}
|
||||
if res.hasMore {
|
||||
t.Error("has_more: want false after draining all pages")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
|
||||
// leaves has_more=true so the caller knows the result is incomplete.
|
||||
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
|
||||
seq := 0
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Every page reports more pages available, with an advancing token so the
|
||||
// loop is stopped by --page-limit, not the non-advancing-token guard.
|
||||
seq++
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
|
||||
}), nil
|
||||
})
|
||||
runtime := newChatMembersTestRuntime(t, rt,
|
||||
map[string]string{"chat-id": "oc_test"},
|
||||
map[string]bool{"page-all": true},
|
||||
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
|
||||
|
||||
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchChatMembers: %v", err)
|
||||
}
|
||||
if len(res.users) != 3 {
|
||||
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
|
||||
}
|
||||
if !res.hasMore {
|
||||
t.Error("has_more: want true (loop cut short by page-limit)")
|
||||
}
|
||||
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "reached page limit (3)") {
|
||||
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
ImChatUpdate,
|
||||
|
||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
dr := common.NewDryRunAPI()
|
||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
||||
}
|
||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
return common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
|
||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
||||
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||
}
|
||||
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
out["from_speaker_id"] = fromSpeakerID
|
||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
||||
out["from_speaker_input"] = fromSpeakerInput
|
||||
}
|
||||
} else {
|
||||
out["from_user_id"] = fromUserID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerInput != "" {
|
||||
return fromSpeakerInput
|
||||
}
|
||||
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerID != "" {
|
||||
return fromSpeakerID
|
||||
}
|
||||
|
||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"speakers": []interface{}{
|
||||
map[string]interface{}{
|
||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
||||
"name": "说话人1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||
// straight through without a second speakerlist call. If the code still
|
||||
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromSpeakerInput string `json:"from_speaker_input"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
||||
}
|
||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||
}
|
||||
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
if strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type minuteSpeaker struct {
|
||||
SpeakerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "speakers")
|
||||
speakers := make([]minuteSpeaker, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
||||
}
|
||||
return speakers, nil
|
||||
}
|
||||
|
||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var matches []minuteSpeaker
|
||||
for _, s := range speakers {
|
||||
if s.Name == name {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
||||
"no speaker named %q in minute transcript", name).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
||||
case 1:
|
||||
return matches[0].SpeakerID, nil
|
||||
default:
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.SpeakerID
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
||||
// The input may already be an opaque speaker_id, or a display name that requires
|
||||
// an internal speaker-list fetch.
|
||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, s := range speakers {
|
||||
if s.SpeakerID == input {
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
return resolveSpeakerIDByName(speakers, input)
|
||||
}
|
||||
|
||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID != "" {
|
||||
return "", fromUserID, nil
|
||||
}
|
||||
|
||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
||||
return fromSpeakerID, "", err
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
||||
speakers := []minuteSpeaker{
|
||||
{SpeakerID: "id_a", Name: "Alice"},
|
||||
{SpeakerID: "id_b", Name: "Bob"},
|
||||
{SpeakerID: "id_c", Name: "Alice"},
|
||||
}
|
||||
|
||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
||||
if err != nil || id != "id_b" {
|
||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
return objectives, nil
|
||||
}
|
||||
|
||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
||||
|
||||
// Add text element
|
||||
textElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
}
|
||||
elements = append(elements, textElem)
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range mentions {
|
||||
mentionElem := ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &mention,
|
||||
},
|
||||
}
|
||||
elements = append(elements, mentionElem)
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
content := buildContentBlock(obj.Text, obj.Mention)
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
|
||||
|
||||
// createKR calls the API to create a key result.
|
||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||
content := buildContentBlock(kr.Text, kr.Mention)
|
||||
content := BuildContentBlock(kr.Text, kr.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
for i, obj := range objectives {
|
||||
// Objective creation
|
||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
||||
objContent := BuildContentBlock(obj.Text, obj.Mention)
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
|
||||
// KR creations
|
||||
for j, kr := range obj.KRs {
|
||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
||||
krContent := BuildContentBlock(kr.Text, kr.Mention)
|
||||
krBody := map[string]interface{}{
|
||||
"content": krContent,
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
|
||||
|
||||
func TestBuildContentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
|
||||
@@ -29,15 +29,10 @@ type RespCategory struct {
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
ID string `json:"id"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
@@ -152,3 +147,145 @@ type RespProgress struct {
|
||||
Content *string `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ========== Simple-style response types (semi-plain text format) ==========
|
||||
|
||||
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespKeyResultSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespObjectiveSimple struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *SemiPlainContent `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
|
||||
type RespProgressSimple struct {
|
||||
ID string `json:"progress_id"`
|
||||
ModifyTime string `json:"modify_time"`
|
||||
CreateTime *string `json:"create_time,omitempty"`
|
||||
Content *SemiPlainContent `json:"content,omitempty"`
|
||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||
}
|
||||
|
||||
// ToSimple converts KeyResult to RespKeyResultSimple.
|
||||
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResultSimple{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = k.Content.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts Objective to RespObjectiveSimple.
|
||||
func (o *Objective) ToSimple() *RespObjectiveSimple {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjectiveSimple{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
result.Content = o.Content.ToSemiPlain()
|
||||
result.Notes = o.Notes.ToSemiPlain()
|
||||
return result
|
||||
}
|
||||
|
||||
// ToSimple converts ProgressV1 to RespProgressSimple.
|
||||
func (p *ProgressV1) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.ModifyTime),
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.Percent,
|
||||
}
|
||||
if p.ProgressRate.Status != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.Status).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Content != nil {
|
||||
resp.Content = p.Content.ToV2().ToSemiPlain()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToSimple converts Progress to RespProgressSimple.
|
||||
func (p *Progress) ToSimple() *RespProgressSimple {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
createTime := formatTimestamp(p.CreateTime)
|
||||
resp := &RespProgressSimple{
|
||||
ID: p.ID,
|
||||
ModifyTime: formatTimestamp(p.UpdateTime),
|
||||
CreateTime: &createTime,
|
||||
}
|
||||
if p.ProgressRate != nil {
|
||||
resp.ProgressRate = &RespProgressRate{
|
||||
Percent: p.ProgressRate.ProgressPercent,
|
||||
}
|
||||
if p.ProgressRate.ProgressStatus != nil {
|
||||
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
|
||||
if s != "" {
|
||||
resp.ProgressRate.Status = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Content = p.Content.ToSemiPlain()
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
style := runtime.Str("style")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := map[string]interface{}{"page_size": "100"}
|
||||
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := map[string]interface{}{"page_size": "100"}
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if style == "simple" {
|
||||
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
obj := &objectives[i]
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
respObj := obj.ToSimple()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToSimple(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
contentText := ""
|
||||
if o.Content != nil {
|
||||
contentText = o.Content.Text
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
notesText := ""
|
||||
if o.Notes != nil {
|
||||
notesText = o.Notes.Text
|
||||
}
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
krText := ""
|
||||
if kr.Content != nil {
|
||||
krText = kr.Content.Text
|
||||
}
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// richtext mode
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
krQuery["page_token"] = pageToken
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
"style": style,
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
// isCurrentActiveCycle checks whether a cycle is currently active:
|
||||
// - current time is within the cycle's start and end time
|
||||
// - cycle status is default (0) or normal (1)
|
||||
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check status: must be default or normal
|
||||
if cycle.CycleStatus == nil {
|
||||
return false
|
||||
}
|
||||
status := *cycle.CycleStatus
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
// Filter current active cycles
|
||||
now := time.Now()
|
||||
currentActiveCycles := make([]*RespCycle, 0)
|
||||
for i := range filtered {
|
||||
if isCurrentActiveCycle(&filtered[i], now) {
|
||||
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
if len(currentActiveCycles) > 0 {
|
||||
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
|
||||
for _, c := range currentActiveCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -5,8 +5,10 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
// Assert current_active_cycles field exists and is a slice
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 0 {
|
||||
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
|
||||
}
|
||||
}
|
||||
|
||||
// --- isCurrentActiveCycle unit tests ---
|
||||
|
||||
func TestIsCurrentActiveCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cycle *Cycle
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "active cycle with normal status",
|
||||
cycle: &Cycle{
|
||||
ID: "c1",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31 23:59:59
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active cycle with default status",
|
||||
cycle: &Cycle{
|
||||
ID: "c2",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusDefault.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cycle with invalid status",
|
||||
cycle: &Cycle{
|
||||
ID: "c3",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusInvalid.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cycle with hidden status",
|
||||
cycle: &Cycle{
|
||||
ID: "c4",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusHidden.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "past cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c5",
|
||||
StartTime: "1704067200000", // 2024-01-01
|
||||
EndTime: "1719791999999", // 2024-06-30
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "future cycle",
|
||||
cycle: &Cycle{
|
||||
ID: "c6",
|
||||
StartTime: "1830297600000", // 2028-01-01
|
||||
EndTime: "1861833599999", // 2028-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil cycle status",
|
||||
cycle: &Cycle{
|
||||
ID: "c7",
|
||||
StartTime: "1767225600000", // 2026-01-01
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: nil,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid start time",
|
||||
cycle: &Cycle{
|
||||
ID: "c8",
|
||||
StartTime: "invalid",
|
||||
EndTime: "1798761599999", // 2026-12-31
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "exact start time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c9",
|
||||
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact end time boundary",
|
||||
cycle: &Cycle{
|
||||
ID: "c10",
|
||||
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
|
||||
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCurrentActiveCycle(tt.cycle, now)
|
||||
if result != tt.expected {
|
||||
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Calculate timestamps relative to now to avoid test expiration
|
||||
now := time.Now().UTC()
|
||||
// Active cycle: 6 months before to 6 months after now
|
||||
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
|
||||
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
|
||||
// Past cycle: 2 years before to 1.5 years before now
|
||||
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
|
||||
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"id": "cycle-active",
|
||||
"start_time": strconv.FormatInt(activeStartMs, 10),
|
||||
"end_time": strconv.FormatInt(activeEndMs, 10),
|
||||
"cycle_status": 1, // normal
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"id": "cycle-past",
|
||||
"start_time": strconv.FormatInt(pastStartMs, 10),
|
||||
"end_time": strconv.FormatInt(pastEndMs, 10),
|
||||
"cycle_status": 2, // invalid
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
rawCurrentActive, ok := data["current_active_cycles"]
|
||||
if !ok {
|
||||
t.Fatal("current_active_cycles field is missing from response")
|
||||
}
|
||||
currentActive, ok := rawCurrentActive.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||
}
|
||||
if len(currentActive) != 1 {
|
||||
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
|
||||
}
|
||||
activeCycle, ok := currentActive[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
|
||||
}
|
||||
if activeCycle["id"] != "cycle-active" {
|
||||
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
|
||||
}
|
||||
|
||||
// Verify removed fields are not present in the response
|
||||
for _, c := range cycles {
|
||||
cycleMap, _ := c.(map[string]interface{})
|
||||
if _, ok := cycleMap["create_time"]; ok {
|
||||
t.Fatal("create_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["update_time"]; ok {
|
||||
t.Fatal("update_time should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["tenant_cycle_id"]; ok {
|
||||
t.Fatal("tenant_cycle_id should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["owner"]; ok {
|
||||
t.Fatal("owner should not be present in response")
|
||||
}
|
||||
if _, ok := cycleMap["score"]; ok {
|
||||
t.Fatal("score should not be present in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,9 @@ package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
ID: c.ID,
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SemiPlainContent (半纯文本格式) ==========
|
||||
|
||||
// Regex patterns for semi-plain text processing (pre-compiled for performance).
|
||||
var (
|
||||
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
|
||||
multiSpaceRE = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// SemiPlainDoc represents a document link in semi-plain content.
|
||||
type SemiPlainDoc struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
|
||||
// It contains plain text, mentions, docs, and images without rich formatting or position info.
|
||||
type SemiPlainContent struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Docs []SemiPlainDoc `json:"docs,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
|
||||
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
|
||||
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
result := &SemiPlainContent{}
|
||||
var textParts []string
|
||||
|
||||
for _, block := range c.Blocks {
|
||||
if block.Paragraph != nil {
|
||||
for _, elem := range block.Paragraph.Elements {
|
||||
switch {
|
||||
case elem.TextRun != nil && elem.TextRun.Text != nil:
|
||||
textParts = append(textParts, *elem.TextRun.Text)
|
||||
case elem.Mention != nil && elem.Mention.UserID != nil:
|
||||
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
|
||||
result.Mention = append(result.Mention, *elem.Mention.UserID)
|
||||
case elem.DocsLink != nil:
|
||||
doc := SemiPlainDoc{}
|
||||
if elem.DocsLink.Title != nil {
|
||||
doc.Title = *elem.DocsLink.Title
|
||||
}
|
||||
if elem.DocsLink.URL != nil {
|
||||
doc.URL = *elem.DocsLink.URL
|
||||
}
|
||||
result.Docs = append(result.Docs, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if block.Gallery != nil {
|
||||
for _, img := range block.Gallery.Images {
|
||||
if img.Src != nil {
|
||||
result.Images = append(result.Images, *img.Src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Text = strings.Join(textParts, "")
|
||||
return result
|
||||
}
|
||||
|
||||
// ToContentBlock converts SemiPlainContent to ContentBlock.
|
||||
// Text and mentions are placed in a single paragraph (text first, then mentions).
|
||||
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
|
||||
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
|
||||
|
||||
// Strip @{userID} placeholders from text to avoid duplicate mentions
|
||||
// (these placeholders are only for readability in the output format)
|
||||
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
|
||||
// Collapse multiple spaces and trim
|
||||
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
|
||||
strippedText = strings.TrimSpace(strippedText)
|
||||
|
||||
// Add text element if stripped text is not empty
|
||||
if strippedText != "" {
|
||||
text := strippedText
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: &text,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add mention elements
|
||||
for _, mention := range s.Mention {
|
||||
m := mention
|
||||
elements = append(elements, ContentParagraphElement{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: &m,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: elements,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContentBlock converts text and mentions to a ContentBlock.
|
||||
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
|
||||
func BuildContentBlock(text string, mentions []string) *ContentBlock {
|
||||
return (&SemiPlainContent{
|
||||
Text: text,
|
||||
Mention: mentions,
|
||||
}).ToContentBlock()
|
||||
}
|
||||
|
||||
// ProgressRateV1 进度率
|
||||
type ProgressRateV1 struct {
|
||||
Percent *float64 `json:"percent,omitempty"`
|
||||
|
||||
@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
// Verify removed fields are not present in RespCycle
|
||||
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
|
||||
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
|
||||
// boolPtr returns a pointer to the given bool value.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// ========== SemiPlainContent Conversion Tests ==========
|
||||
|
||||
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello world"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Hello world" {
|
||||
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 0 {
|
||||
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
|
||||
}
|
||||
if len(sp.Docs) != 0 {
|
||||
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
|
||||
}
|
||||
if len(sp.Images) != 0 {
|
||||
t.Fatalf("expected 0 images, got %d", len(sp.Images))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Hello "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||
Mention: &ContentMention{
|
||||
UserID: strPtr("ou_123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr(", how are you?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
// Text includes @{userID} placeholder to preserve positional context
|
||||
if sp.Text != "Hello @{ou_123} , how are you?" {
|
||||
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
|
||||
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Check out this doc: "),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
|
||||
DocsLink: &ContentDocsLink{
|
||||
Title: strPtr("Design Doc"),
|
||||
URL: strPtr("https://example.feishu.cn/docx/xxx"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
BlockElementType: BlockElementTypeGallery.Ptr(),
|
||||
Gallery: &ContentGallery{
|
||||
Images: []ContentImageItem{
|
||||
{
|
||||
Src: strPtr("https://example.com/img1.png"),
|
||||
},
|
||||
{
|
||||
Src: strPtr("https://example.com/img2.png"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp == nil {
|
||||
t.Fatal("expected non-nil SemiPlainContent")
|
||||
}
|
||||
if sp.Text != "Check out this doc: " {
|
||||
t.Fatalf("unexpected text: '%s'", sp.Text)
|
||||
}
|
||||
if len(sp.Docs) != 1 {
|
||||
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
|
||||
}
|
||||
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
|
||||
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
|
||||
}
|
||||
if len(sp.Images) != 2 {
|
||||
t.Fatalf("expected 2 images, got %d", len(sp.Images))
|
||||
}
|
||||
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
|
||||
t.Fatalf("unexpected images: %v", sp.Images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var cb *ContentBlock
|
||||
sp := cb.ToSemiPlain()
|
||||
if sp != nil {
|
||||
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Hello world",
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
block := cb.Blocks[0]
|
||||
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||
t.Fatal("expected paragraph block")
|
||||
}
|
||||
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
|
||||
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
|
||||
}
|
||||
elem := block.Paragraph.Elements[0]
|
||||
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected textRun element")
|
||||
}
|
||||
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
|
||||
t.Fatalf("unexpected text: %v", elem.TextRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Please review",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
if len(cb.Blocks) != 1 {
|
||||
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
|
||||
t.Fatal("unexpected first element")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: " ",
|
||||
Mention: []string{"ou_123"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Empty text should be skipped, only mention remains
|
||||
if len(elems) != 1 {
|
||||
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected mention element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
sp := &SemiPlainContent{
|
||||
Text: "Test",
|
||||
Mention: []string{"ou_123"},
|
||||
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
|
||||
Images: []string{"https://img.png"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Docs and images are ignored in input conversion
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Simulate round-trip: output format has @{userID} in text,
|
||||
// input conversion should strip them to avoid duplicate mentions
|
||||
sp := &SemiPlainContent{
|
||||
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
|
||||
Mention: []string{"ou_zhangsan", "ou_lisi"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have 3 elements: 1 text (stripped) + 2 mentions
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||
}
|
||||
// Text should have placeholders stripped
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||
t.Fatal("expected first element to be textRun")
|
||||
}
|
||||
// Note: space before comma is preserved from the placeholder's trailing space
|
||||
expectedText := "任务一 ,任务二"
|
||||
if *elems[0].TextRun.Text != expectedText {
|
||||
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
|
||||
}
|
||||
// Mentions should be preserved as separate elements
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
|
||||
t.Fatal("unexpected second element")
|
||||
}
|
||||
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
|
||||
t.Fatal("unexpected third element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Text that is only placeholders should result in no text element
|
||||
sp := &SemiPlainContent{
|
||||
Text: " @{ou_123} @{ou_456} ",
|
||||
Mention: []string{"ou_123", "ou_456"},
|
||||
}
|
||||
cb := sp.ToContentBlock()
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
// Should have only 2 mention elements, no text element
|
||||
if len(elems) != 2 {
|
||||
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
|
||||
}
|
||||
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected first element to be mention")
|
||||
}
|
||||
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
|
||||
t.Fatal("expected second element to be mention")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sp *SemiPlainContent
|
||||
cb := sp.ToContentBlock()
|
||||
if cb != nil {
|
||||
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentBlock_Conversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||
if cb == nil {
|
||||
t.Fatal("expected non-nil ContentBlock")
|
||||
}
|
||||
elems := cb.Blocks[0].Paragraph.Elements
|
||||
if len(elems) != 3 {
|
||||
t.Fatalf("expected 3 elements, got %d", len(elems))
|
||||
}
|
||||
if *elems[0].TextRun.Text != "Test text" {
|
||||
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
|
||||
}
|
||||
if *elems[1].Mention.UserID != "ou_123" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
|
||||
}
|
||||
if *elems[2].Mention.UserID != "ou_456" {
|
||||
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToSimpleMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test Objective.ToSimple()
|
||||
text := "Objective text"
|
||||
obj := &Objective{
|
||||
ID: "obj-1",
|
||||
Content: BuildContentBlock(text, []string{"ou_123"}),
|
||||
Notes: BuildContentBlock("Note text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
|
||||
CycleID: "cycle-1",
|
||||
Score: float64Ptr(0.7),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1735776000000"),
|
||||
}
|
||||
simpleObj := obj.ToSimple()
|
||||
if simpleObj == nil {
|
||||
t.Fatal("expected non-nil RespObjectiveSimple")
|
||||
}
|
||||
if simpleObj.ID != "obj-1" {
|
||||
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedContentText := "Objective text @{ou_123} "
|
||||
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
|
||||
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
|
||||
}
|
||||
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
|
||||
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
|
||||
}
|
||||
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
|
||||
t.Fatalf("unexpected score: %v", simpleObj.Score)
|
||||
}
|
||||
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
|
||||
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
|
||||
}
|
||||
|
||||
// Test KeyResult.ToSimple()
|
||||
kr := &KeyResult{
|
||||
ID: "kr-1",
|
||||
ObjectiveID: "obj-1",
|
||||
Content: BuildContentBlock("KR text", nil),
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
|
||||
Score: float64Ptr(0.5),
|
||||
}
|
||||
simpleKR := kr.ToSimple()
|
||||
if simpleKR == nil {
|
||||
t.Fatal("expected non-nil RespKeyResultSimple")
|
||||
}
|
||||
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
|
||||
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
|
||||
}
|
||||
|
||||
// Test ProgressV1.ToSimple()
|
||||
progress := &ProgressV1{
|
||||
ID: "prog-1",
|
||||
ModifyTime: "1735776000000",
|
||||
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
|
||||
}
|
||||
simpleProgress := progress.ToSimple()
|
||||
if simpleProgress == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple")
|
||||
}
|
||||
// Text includes @{userID} placeholder for positional context
|
||||
expectedProgressText := "Progress text @{ou_mention} "
|
||||
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
|
||||
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
|
||||
}
|
||||
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
|
||||
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
|
||||
}
|
||||
|
||||
// Test Progress.ToSimple() (V2 progress record)
|
||||
progressV2 := &Progress{
|
||||
ID: "prog-v2-1",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
|
||||
ProgressRate: &ProgressRate{
|
||||
ProgressPercent: float64Ptr(80.0),
|
||||
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
|
||||
},
|
||||
}
|
||||
simpleProgressV2 := progressV2.ToSimple()
|
||||
if simpleProgressV2 == nil {
|
||||
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
|
||||
}
|
||||
if simpleProgressV2.ID != "prog-v2-1" {
|
||||
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
|
||||
}
|
||||
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
|
||||
t.Fatal("expected non-empty CreateTime for Progress V2")
|
||||
}
|
||||
expectedV2Text := "V2 progress text @{ou_v2_mention} "
|
||||
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
|
||||
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
|
||||
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
|
||||
}
|
||||
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
|
||||
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
|
||||
}
|
||||
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
|
||||
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
|
||||
}
|
||||
}
|
||||
|
||||
// listTypePtr returns a pointer to the given ListType value.
|
||||
func listTypePtr(v ListType) *ListType { return &v }
|
||||
|
||||
311
shortcuts/okr/okr_patch.go
Normal file
311
shortcuts/okr/okr_patch.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// patchParams holds the parsed parameters for the patch operation.
|
||||
type patchParams struct {
|
||||
Level string
|
||||
TargetID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
Score *float64
|
||||
Deadline *string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
|
||||
p := &patchParams{
|
||||
Level: runtime.Str("level"),
|
||||
TargetID: runtime.Str("target-id"),
|
||||
Style: runtime.Str("style"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
hasField := false
|
||||
|
||||
// Parse content if provided
|
||||
if contentStr := runtime.Str("content"); contentStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
p.Content = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
p.Content = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse notes if provided (only for objective)
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
hasField = true
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
|
||||
}
|
||||
if p.Style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
|
||||
}
|
||||
p.Notes = sp.ToContentBlock()
|
||||
} else {
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
|
||||
}
|
||||
p.Notes = &cb
|
||||
}
|
||||
}
|
||||
|
||||
// Parse score if provided
|
||||
if scoreStr := runtime.Str("score"); scoreStr != "" {
|
||||
hasField = true
|
||||
score, err := strconv.ParseFloat(scoreStr, 64)
|
||||
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
|
||||
}
|
||||
if score < 0 || score > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
|
||||
}
|
||||
// Check for exactly one decimal place
|
||||
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
|
||||
parts := strings.Split(scoreStrTrimmed, ".")
|
||||
if len(parts) == 2 && len(parts[1]) > 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
|
||||
}
|
||||
// Validation ensures at most one decimal place, so score is already correctly formatted
|
||||
p.Score = &score
|
||||
}
|
||||
|
||||
// Parse deadline if provided
|
||||
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
|
||||
hasField = true
|
||||
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
|
||||
}
|
||||
if deadlineMs <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
|
||||
}
|
||||
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
|
||||
// Anything less than 1e12 is likely seconds or a wrong unit
|
||||
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
|
||||
}
|
||||
p.Deadline = &deadlineStr
|
||||
}
|
||||
|
||||
// At least one field must be provided
|
||||
if !hasField {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRPatch patches an objective or key result.
|
||||
var OKRPatch = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+patch",
|
||||
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
|
||||
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
if targetID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
// Delegate content/notes/score/deadline validation to parsePatchParams
|
||||
if _, err := parsePatchParams(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
api := common.NewDryRunAPI()
|
||||
if p.Level == "objective" {
|
||||
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
|
||||
Set("objective_id", p.TargetID)
|
||||
} else {
|
||||
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
|
||||
Set("key_result_id", p.TargetID)
|
||||
}
|
||||
return api.Params(params).Body(body).
|
||||
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
|
||||
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parsePatchParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := make(map[string]interface{})
|
||||
if p.Content != nil {
|
||||
body["content"] = p.Content
|
||||
}
|
||||
if p.Notes != nil {
|
||||
body["notes"] = p.Notes
|
||||
}
|
||||
if p.Score != nil {
|
||||
body["score"] = *p.Score
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
body["deadline"] = *p.Deadline
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": p.UserIDType,
|
||||
}
|
||||
|
||||
var path string
|
||||
if p.Level == "objective" {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
|
||||
} else {
|
||||
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"level": p.Level,
|
||||
"target_id": p.TargetID,
|
||||
"patched": map[string]bool{
|
||||
"content": p.Content != nil,
|
||||
"notes": p.Notes != nil,
|
||||
"score": p.Score != nil,
|
||||
"deadline": p.Deadline != nil,
|
||||
},
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
|
||||
if p.Content != nil {
|
||||
fmt.Fprintf(w, " - content: updated\n")
|
||||
}
|
||||
if p.Notes != nil {
|
||||
fmt.Fprintf(w, " - notes: updated\n")
|
||||
}
|
||||
if p.Score != nil {
|
||||
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
|
||||
}
|
||||
if p.Deadline != nil {
|
||||
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1350
shortcuts/okr/okr_patch_test.go
Normal file
1350
shortcuts/okr/okr_patch_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
|
||||
|
||||
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
// Validate mention IDs are non-empty
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
// Build ContentBlock from semi-plain content (text + mentions)
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
targetType := runtime.Str("target-type")
|
||||
targetTypeVal := targetTypeAllowed[targetType]
|
||||
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
|
||||
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
// If user provided docs or images in simple mode, warn that they are ignored
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
|
||||
}
|
||||
|
||||
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", "not-json",
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "abc",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "invalid",
|
||||
})
|
||||
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--user-id-type", "invalid",
|
||||
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "999999999999",
|
||||
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "abc",
|
||||
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-status", "invalid_status",
|
||||
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--dry-run",
|
||||
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--progress-percent", "75",
|
||||
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "456",
|
||||
"--target-type", "key_result",
|
||||
})
|
||||
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--target-id", "789",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple mode tests ---
|
||||
|
||||
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v1/progress_records/",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "300",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Use default style (simple) without specifying --style
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "300" {
|
||||
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v1/progress_records/",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "400",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Explicitly specify --style simple with mentions
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
|
||||
"--style", "simple",
|
||||
"--target-id", "456",
|
||||
"--target-type", "key_result",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "400" {
|
||||
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"missing closing brace`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid semi-plain JSON")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":" ","mention":[]}`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty text in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for docs in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||
"+progress-create",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "POST") {
|
||||
t.Fatalf("dry-run output should contain POST method, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
style := runtime.Str("style")
|
||||
|
||||
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
||||
|
||||
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
if len(resp.Content.Mention) > 0 {
|
||||
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
|
||||
|
||||
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
||||
style := runtime.Str("style")
|
||||
content := runtime.Str("content")
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
var contentV1 *ContentBlockV1
|
||||
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
contentV1 = sp.ToContentBlock().ToV1()
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
contentV1 = cb.ToV1()
|
||||
}
|
||||
contentV1 := cb.ToV1()
|
||||
|
||||
var progressRate *ProgressRateV1
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "progress-percent", Desc: "progress percentage"},
|
||||
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
progressID := runtime.Str("progress-id")
|
||||
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||
return err
|
||||
}
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
// Validate content based on style
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||
}
|
||||
for i, m := range sp.Mention {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||
}
|
||||
} else {
|
||||
// richtext mode
|
||||
var cb ContentBlock
|
||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
if v := runtime.Str("progress-percent"); v != "" {
|
||||
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
resp := record.ToResp()
|
||||
result := map[string]interface{}{
|
||||
"progress": resp,
|
||||
}
|
||||
style := runtime.Str("style")
|
||||
var result map[string]interface{}
|
||||
if style == "simple" {
|
||||
resp := record.ToSimple()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := record.ToResp()
|
||||
result = map[string]interface{}{
|
||||
"progress": resp,
|
||||
"style": style,
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||
}
|
||||
if resp.Content != nil {
|
||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --progress-id")
|
||||
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "abc",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --progress-id")
|
||||
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", "not-json",
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --content JSON")
|
||||
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-percent", "-999999999999",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-status", "invalid_status",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "456",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "456",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
"--progress-percent", "50",
|
||||
"--progress-status", "overdue",
|
||||
"--dry-run",
|
||||
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "789",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
|
||||
"+progress-update",
|
||||
"--progress-id", "999",
|
||||
"--content", validContentBlockJSON,
|
||||
"--style", "richtext",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Simple mode tests ---
|
||||
|
||||
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v1/progress_records/500",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "500",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Use default style (simple) without specifying --style
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "500",
|
||||
"--content", validSemiPlainJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "500" {
|
||||
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/okr/v1/progress_records/600",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"progress_id": "600",
|
||||
"modify_time": "1735776000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Explicitly specify --style simple with mentions and progress rate
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "600",
|
||||
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
|
||||
"--style", "simple",
|
||||
"--progress-percent", "80",
|
||||
"--progress-status", "normal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
pr, _ := data["progress"].(map[string]interface{})
|
||||
if pr == nil {
|
||||
t.Fatal("expected progress in output")
|
||||
}
|
||||
if pr["progress_id"] != "600" {
|
||||
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"invalid json`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid semi-plain JSON")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty mention in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "123",
|
||||
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for images in simple mode")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||
}
|
||||
if validationErr.Param != "--content" {
|
||||
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||
"+progress-update",
|
||||
"--progress-id", "700",
|
||||
"--content", validSemiPlainJSON,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "PUT") {
|
||||
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
OKRPatch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||
cmd, _, err := program.Find([]string{"docs", name})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != name {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||
}
|
||||
if cmd.Flags().Lookup("api-version") != nil {
|
||||
t.Fatalf("docs %s should not expose --api-version", name)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -14,8 +14,5 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
SlidesHistoryList,
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type slidesHistoryListSpec struct {
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type slidesHistoryRevertSpec struct {
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type slidesHistoryRevertStatusSpec struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseSlidesHistoryPresentation(runtime *common.RuntimeContext) (presentationRef, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return presentationRef{}, err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return presentationRef{}, err
|
||||
}
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil || version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by slides +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slidesHistoryListParams(spec slidesHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func slidesHistoryRevertBody(spec slidesHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func slidesHistoryStatusParams(spec slidesHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func slidesHistoryAPIPath(presentationID, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/%s", validate.EncodePathSegment(presentationID), suffix)
|
||||
}
|
||||
|
||||
func newSlidesHistoryDryRun(ref presentationRef, desc string) (*common.DryRunAPI, string) {
|
||||
dry := common.NewDryRunAPI()
|
||||
presentationID := ref.Token
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki then " + desc).
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("OpenAPI: " + desc)
|
||||
}
|
||||
return dry, presentationID
|
||||
}
|
||||
|
||||
// SlidesHistoryList lists history versions of a Slides XML presentation.
|
||||
var SlidesHistoryList = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-list",
|
||||
Description: "List Slides presentation history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSlidesHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spec := slidesHistoryListSpec{
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "list Slides history versions")
|
||||
return dry.
|
||||
GET(slidesHistoryAPIPath(presentationID, "histories")).
|
||||
Params(slidesHistoryListParams(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := slidesHistoryListSpec{
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
slidesHistoryAPIPath(presentationID, "histories"),
|
||||
slidesHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SlidesHistoryRevert reverts a Slides XML presentation to a history version.
|
||||
var SlidesHistoryRevert = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Slides presentation to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from slides +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSlidesHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSlidesHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spec := slidesHistoryRevertSpec{
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "revert Slides history")
|
||||
return dry.
|
||||
POST(slidesHistoryAPIPath(presentationID, "history/revert")).
|
||||
Body(slidesHistoryRevertBody(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := slidesHistoryRevertSpec{
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
slidesHistoryAPIPath(presentationID, "history/revert"),
|
||||
nil,
|
||||
slidesHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SlidesHistoryRevertStatus gets the status of a Slides history revert task.
|
||||
var SlidesHistoryRevertStatus = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Slides history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by slides +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spec := slidesHistoryRevertStatusSpec{
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "get Slides history revert status")
|
||||
return dry.
|
||||
GET(slidesHistoryAPIPath(presentationID, "history/revert_status")).
|
||||
Params(slidesHistoryStatusParams(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := slidesHistoryRevertStatusSpec{
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
slidesHistoryAPIPath(presentationID, "history/revert_status"),
|
||||
slidesHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSlidesHistoryDeclaredScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantBase []string
|
||||
wantFull []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlidesHistoryList,
|
||||
wantBase: []string{"slides:presentation:read"},
|
||||
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
|
||||
},
|
||||
{
|
||||
name: "revert",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
wantBase: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
wantFull: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
shortcut: SlidesHistoryRevertStatus,
|
||||
wantBase: []string{"slides:presentation:read"},
|
||||
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.shortcut.ScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantBase) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, tt.wantBase)
|
||||
}
|
||||
if got := tt.shortcut.ScopesForIdentity("bot"); !reflect.DeepEqual(got, tt.wantBase) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, tt.wantBase)
|
||||
}
|
||||
if got := tt.shortcut.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantFull) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, tt.wantFull)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{
|
||||
name: "list rejects unsupported presentation input",
|
||||
shortcut: SlidesHistoryList,
|
||||
args: []string{"+history-list", "--presentation", "tmp/wiki/wikcn123", "--as", "bot"},
|
||||
param: "--presentation",
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: SlidesHistoryList,
|
||||
args: []string{"+history-list", "--presentation", "presHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid history version id",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: SlidesHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--presentation", "presHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, tt.shortcut, tt.args)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevert, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevertStatus, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryDryRunWithWikiPresentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
|
||||
"presentation": "https://example.feishu.cn/wiki/wikcn123",
|
||||
"page-size": "20",
|
||||
})
|
||||
dry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
|
||||
if len(dry.API) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2: %#v", len(dry.API), dry.API)
|
||||
}
|
||||
if got, want := dry.API[0].URL, "/open-apis/wiki/v2/spaces/get_node"; got != want {
|
||||
t.Fatalf("wiki dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := dry.API[0].Params["token"]; got != "wikcn123" {
|
||||
t.Fatalf("wiki node parameter mismatch: got %#v, want placeholder node id", got)
|
||||
}
|
||||
if got, want := dry.API[1].URL, "/open-apis/slides_ai/v1/xml_presentations/%3Cresolved_slides_token%3E/histories"; got != want {
|
||||
t.Fatalf("history dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
|
||||
"+history-list",
|
||||
"--presentation", "presHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := capturedQuery.Get("page_size"); got != "5" {
|
||||
t.Fatalf("page_size query = %q, want 5", got)
|
||||
}
|
||||
if got := capturedQuery.Get("page_token"); got != "page_token_1" {
|
||||
t.Fatalf("page_token query = %q, want page_token_1", got)
|
||||
}
|
||||
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--presentation", "presHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "done",
|
||||
"history_version_id": "11",
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--presentation", "presHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := capturedQuery.Get("task_id"); got != "task_1" {
|
||||
t.Fatalf("task_id query = %q, want task_1", got)
|
||||
}
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "done" {
|
||||
t.Fatalf("status = %#v, want done", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteResolvesWikiPresentation(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "presReal",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presReal/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
|
||||
"+history-list",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("has_more = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
type slidesHistoryDryRunOutput struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
func newSlidesHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeSlidesHistoryDryRun(t *testing.T, dry *common.DryRunAPI) slidesHistoryDryRunOutput {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
var out slidesHistoryDryRunOutput
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeSlidesHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。
|
||||
|
||||
## 路由优先级(先判断是不是审批,再选命令)
|
||||
|
||||
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
|
||||
|
||||
### 明确归 `lark-approval` 的高优先级语义
|
||||
|
||||
出现以下任一语义时,优先走 `lark-approval`:
|
||||
|
||||
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
|
||||
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
|
||||
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
|
||||
|
||||
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
| 想做什么 | 命令 | 按需读取 reference |
|
||||
|---|---|---------------------------------------------------------------------------------|
|
||||
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
|
||||
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
|
||||
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
|
||||
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
|
||||
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
|
||||
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作
|
||||
|
||||
## 执行原则(减少误路由、误重试和无效消耗)
|
||||
|
||||
### 1) 先拿最小必要信息,再执行
|
||||
|
||||
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
|
||||
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
|
||||
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
|
||||
|
||||
### 2) 已知对象时直达动作
|
||||
|
||||
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
|
||||
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
|
||||
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
|
||||
|
||||
### 3) 错误码驱动,而不是盲目重试
|
||||
|
||||
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
|
||||
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
|
||||
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次
|
||||
|
||||
## 写操作失败处理:1395001 决策树
|
||||
|
||||
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
|
||||
|
||||
1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次
|
||||
2. 优先从以下角度解释:
|
||||
- 任务可能已被他人处理
|
||||
- 单据状态已变化,当前动作已不再允许
|
||||
- 当前用户已不具备该任务的操作资格
|
||||
- 当前节点或单据状态不支持该操作
|
||||
3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环
|
||||
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
|
||||
|
||||
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
@@ -27,14 +81,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
## 不在本 skill 范围
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
@@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
|
||||
@@ -8,28 +8,83 @@ metadata:
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段;**references 是第一信息源**,只有在 reference 未覆盖的原生 / 高级场景下,才额外用 `lark-cli ... --help`、`lark-cli schema` 等方式补充确认字段。
|
||||
|
||||
## 路由优先级(先判断是不是审批,再选命令)
|
||||
|
||||
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
|
||||
|
||||
### 明确归 `lark-approval` 的高优先级语义
|
||||
|
||||
出现以下任一语义时,优先走 `lark-approval`:
|
||||
|
||||
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
|
||||
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
|
||||
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
|
||||
|
||||
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
| 想做什么 | 命令 | 按需读取 reference |
|
||||
|---|---|---------------------------------------------------------------------------------|
|
||||
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
|
||||
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
|
||||
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
|
||||
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
|
||||
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
|
||||
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作
|
||||
|
||||
## 执行原则(减少误路由、误重试和无效消耗)
|
||||
|
||||
### 1) 先拿最小必要信息,再执行
|
||||
|
||||
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
|
||||
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
|
||||
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
|
||||
|
||||
### 2) 已知对象时直达动作
|
||||
|
||||
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
|
||||
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
|
||||
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
|
||||
|
||||
### 3) 错误码驱动,而不是盲目重试
|
||||
|
||||
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
|
||||
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
|
||||
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次
|
||||
|
||||
## 写操作失败处理:1395001 决策树
|
||||
|
||||
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
|
||||
|
||||
1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次
|
||||
2. 优先从以下角度解释:
|
||||
- 任务可能已被他人处理
|
||||
- 单据状态已变化,当前动作已不再允许
|
||||
- 当前用户已不具备该任务的操作资格
|
||||
- 当前节点或单据状态不支持该操作
|
||||
3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环
|
||||
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
|
||||
|
||||
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
@@ -39,18 +94,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
128
skills/lark-approval/references/lark-approval-approvals-get.md
Normal file
128
skills/lark-approval/references/lark-approval-approvals-get.md
Normal file
@@ -0,0 +1,128 @@
|
||||
|
||||
# approval approvals get
|
||||
|
||||
获取单个审批定义详情(用户级只读操作)。适合在发起审批实例前,先确认审批名称、表单控件结构、选项值范围以及流程节点信息。
|
||||
|
||||
需要的 scopes: ["approval:approval:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按 approval_code 查询审批定义详情
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览顶层字段
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;通常来自 `approval approvals search` 的结果 |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批定义详情通常按当前用户可见范围读取 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 常见输入来源
|
||||
|
||||
如果你已经有 `approval_code`,可直接查询:
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
```
|
||||
|
||||
如果你还没有 `approval_code`,先搜索可发起审批定义:
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
```
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中,优先关注以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `approval_code` | 审批定义 Code |
|
||||
| `approval_name` | 审批定义名称;确认是不是用户想发起的那张单 |
|
||||
| `form` | 表单定义快照;用于识别控件 `id`、`type`、选项值范围、明细子控件结构 |
|
||||
| `node_list` | 流程节点列表;用于识别节点 key、是否需要补充审批人、是否允许多人 |
|
||||
|
||||
## form 的使用重点
|
||||
|
||||
`form` 最重要的作用是帮助 agent **识别怎么组装 `instances.create.data.form`**,而不是直接把它原样提交出去。
|
||||
|
||||
重点看:
|
||||
|
||||
| 字段 / 结构 | 说明 |
|
||||
|------|------|
|
||||
| `form[].id` | 控件 ID;后续创建实例时必须使用 |
|
||||
| `form[].type` | 控件类型,例如 `input`、`date`、`radio`、`checkbox`、`fieldList` |
|
||||
| `form[].value` / 选项定义 | 用来识别可选值范围、默认值或选项值 |
|
||||
| 明细 / 子控件结构 | 用于识别 `fieldList`、控件组等复杂控件的子字段结构 |
|
||||
|
||||
**注意:`approvals.get.form` 不是 `instances.create` 可直接复用的 payload 模板。** 它是“定义快照”,主要用于识别字段结构与选项值范围。
|
||||
|
||||
## node_list 的使用重点
|
||||
|
||||
`node_list` 主要用于后续决定是否要补 `node_approver_list` / `node_cc_list`。
|
||||
|
||||
重点看:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `node_list[].custom_node_id` | 自定义节点标识;后续补节点参数时优先作为 key |
|
||||
| `node_list[].node_id` | 节点 ID;若没有 `custom_node_id`,通常退回用它做 key |
|
||||
| `node_list[].need_approver` | 是否要求发起人补充审批人 |
|
||||
| `node_list[].approver_chosen_multi` | 是否允许为该节点选择多个审批人 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是发起原生审批实例前的必要只读步骤。** 推荐固定走:`approvals search` -> `approvals get` -> `instances create`。
|
||||
- **如果用户已经明确给了 `approval_code`,直接用这个命令。** 不必再走 `approvals search`。
|
||||
- **先确认 `approval_name`。** 避免把相似名称的审批定义搞混。
|
||||
- **先用 `form` 识别控件结构,再组装创建 payload。** 不要在未看详情时猜控件 `id`、`type` 或选项值。
|
||||
- **先用 `node_list` 看是否需要补审批人。** 若某节点 `need_approver=true`,创建实例时通常要补 `node_approver_list`。
|
||||
- **`node_list` 的 key 优先取 `custom_node_id`。** 若不存在,再使用 `node_id`。
|
||||
- **`approver_chosen_multi=false` 时,一个节点通常只能补一个审批人。**
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
读取定义详情后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 发起原生审批实例
|
||||
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
|
||||
```
|
||||
|
||||
如果需要进一步理解控件取值与节点参数,优先参考:
|
||||
|
||||
- `lark-approval-instance-form-control-parameters.md`
|
||||
- `lark-approval-instance-value-sourcing.md`
|
||||
- `lark-approval-initiate.md`
|
||||
|
||||
## 结果整理方式
|
||||
|
||||
**将结果整理为“审批定义概览 + 表单结构摘要 + 节点要求摘要”。**
|
||||
|
||||
建议输出成下面这种结构:
|
||||
|
||||
```text
|
||||
审批定义:请假申请
|
||||
approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
|
||||
|
||||
表单控件摘要:
|
||||
- leave_type: radio,可选值 [annual_leave, sick_leave]
|
||||
- reason: textarea
|
||||
- start_end: dateInterval
|
||||
|
||||
节点要求摘要:
|
||||
- manager_node:need_approver=true,approver_chosen_multi=false
|
||||
- hr_node:need_approver=false
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
# approval approvals search
|
||||
|
||||
搜索**当前用户可发起**的审批定义(launchable approvals)。只读操作,不会创建审批实例。
|
||||
|
||||
需要的 scopes: ["approval:approval:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词搜索可发起审批定义
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval approvals search --data '{"keyword":"请假", "page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览候选定义
|
||||
lark-cli approval approvals search --data '{"keyword":"出差"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `keyword` | 是 | 搜索关键词,例如 `请假`、`报销`、`出差`、`采购` |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;“可发起审批定义”是面向当前用户的查询 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 这个命令解决什么问题
|
||||
|
||||
当用户只有自然语言意图,还没有 `approval_code` 时,先用它把“可发起的审批定义候选项”找出来。
|
||||
|
||||
典型场景:
|
||||
|
||||
- “帮我找一下请假审批”
|
||||
- “有哪些可以发起的报销单?”
|
||||
- “先搜一下出差审批,再帮我提单”
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果里,优先关注以下字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `approval_code` | 审批定义 Code;后续 `approvals get` 和 `instances create` 都要用它 |
|
||||
| `approval_name` | 审批定义名称;给用户做候选选择时最关键 |
|
||||
| `is_external` | 是否为三方审批定义;`true` 表示不能走原生 `instances.create` |
|
||||
| `create_link` | 三方审批定义的发起链接;`is_external=true` 时优先返回给用户 |
|
||||
|
||||
## 使用规则
|
||||
|
||||
- **这是发起审批工作流的第一步。** 标准顺序是:`approvals search` -> `approvals get` -> `instances create`。
|
||||
- **搜索结果为空时,不要猜。** 直接告诉用户当前关键词下没有可发起定义,并建议用户换关键词。
|
||||
- **命中多个结果时,不要替用户拍板。** 先把候选定义列出来,让用户选择目标审批定义。
|
||||
- **`is_external=true` 时不要调用 `approval instances create`。** 这类定义属于三方审批,优先返回 `create_link` 并说明需要通过链接发起。
|
||||
- **只有 `is_external=false` 的原生定义,才继续 `approvals get`。**
|
||||
- **如果用户已经明确给出 `approval_code`,不要再 search。** 直接执行 `approval approvals get`。
|
||||
|
||||
## 结果整理方式
|
||||
|
||||
**将结果整理为候选清单,优先展示“名称 + approval_code + 是否三方定义 + 下一步建议”。**
|
||||
|
||||
建议输出成下面这种结构:
|
||||
|
||||
```text
|
||||
找到 3 个可发起审批定义:
|
||||
|
||||
1. 请假申请
|
||||
- approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85
|
||||
- is_external: false
|
||||
- next: 可继续读取 definitions 详情(approvals get)
|
||||
|
||||
2. 差旅报销
|
||||
- approval_code: 99887766-xxxx
|
||||
- is_external: true
|
||||
- next: 返回 create_link,引导用户通过链接发起
|
||||
```
|
||||
|
||||
## 常见后续操作
|
||||
|
||||
### 1)用户选中了某个定义,继续查看详情
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals get --params '{"approval_code":"<APPROVAL_CODE>"}' --as user
|
||||
```
|
||||
|
||||
### 2)确认是原生定义后,再准备发起审批实例
|
||||
|
||||
```bash
|
||||
lark-cli approval instances create --data '{"approval_code":"<APPROVAL_CODE>","form":"[...]"}' --as user --yes
|
||||
```
|
||||
|
||||
### 3)确认是三方定义时,直接返回链接
|
||||
|
||||
当 `is_external=true` 时,优先向用户返回 `create_link`,说明该审批需在三方系统或跳转页面中发起,而不是通过原生 `instances.create`。
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
## 执行摘要
|
||||
|
||||
- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。
|
||||
- **原生审批提单如果用户未明确给出 `approval_code`,必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||
- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`。
|
||||
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
|
||||
- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
|
||||
- **先读控件参数 reference 和值来源 reference,再读本文里的创建参数规则。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md)。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances create --data.form` 中,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。
|
||||
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,执行前,让用户确认最终定义、表单值和节点参数;真正执行时显式传 `--yes`。
|
||||
|
||||
## 适用场景
|
||||
|
||||
@@ -20,11 +21,10 @@
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
|
||||
- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。
|
||||
- **严禁在未先阅读本文中的创建参数规则、[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances create`。
|
||||
- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
|
||||
- **严禁对三方定义调用 `instances.create`。**
|
||||
- **严禁对三方定义调用 `instances create`。**
|
||||
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
|
||||
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
|
||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||
@@ -33,10 +33,9 @@
|
||||
|
||||
### 1. 搜索可发起审批定义
|
||||
|
||||
先用 `schema` 看参数,再搜索定义:
|
||||
先搜索定义:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.search
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
```
|
||||
|
||||
@@ -44,7 +43,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
|
||||
- 若结果为空,告诉用户当前关键词下没有可发起定义。
|
||||
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances create`。
|
||||
- 只有 `is_external=false` 的原生定义才继续下一步。
|
||||
|
||||
### 2. 获取审批定义详情
|
||||
@@ -52,7 +51,6 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
拿到 `approval_code` 后,读取定义详情:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.get
|
||||
lark-cli approval approvals get \
|
||||
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
|
||||
```
|
||||
@@ -63,12 +61,30 @@ lark-cli approval approvals get \
|
||||
- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
|
||||
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
|
||||
|
||||
### 3. 组装 `form`
|
||||
### 3. 创建请求参数速查
|
||||
|
||||
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
输入参数如下:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
||||
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
|
||||
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
||||
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
||||
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
||||
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
### 4. 组装 `form`
|
||||
|
||||
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
|
||||
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
|
||||
- `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
|
||||
@@ -100,7 +116,7 @@ lark-cli approval approvals get \
|
||||
- `input` / `textarea`: `value` 是字符串
|
||||
- `date`: `value` 是 RFC3339 时间字符串
|
||||
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id`
|
||||
- `checkbox` / `checkboxV2`: `value` 是选项值数组
|
||||
- `number`: `value` 是数字
|
||||
- `amount`: `value` 是数字,还要带 `currency`
|
||||
@@ -129,7 +145,7 @@ lark-cli approval approvals get \
|
||||
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
|
||||
- 不要把控件组整体当成普通字符串或扁平对象提交
|
||||
|
||||
### 4. 组装节点参数
|
||||
### 5. 组装节点参数
|
||||
|
||||
从 `node_list` 推导节点参数:
|
||||
|
||||
@@ -139,13 +155,13 @@ lark-cli approval approvals get \
|
||||
- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
|
||||
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
|
||||
|
||||
### 5. 创建审批实例
|
||||
### 6. 创建审批实例
|
||||
|
||||
先看 `schema`,确认最终结构后再执行:
|
||||
创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
确认最终表单值和节点参数后再执行:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.instances.create
|
||||
|
||||
lark-cli approval instances create \
|
||||
--data '{
|
||||
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
|
||||
@@ -157,6 +173,8 @@ lark-cli approval instances create \
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -170,7 +188,7 @@ lark-cli approval instances create \
|
||||
|
||||
优先级固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。
|
||||
1. 本文中的创建请求参数、节点参数和返回结果说明:决定 `instances create` 要传哪些字段、怎么执行、成功后回什么。
|
||||
2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
|
||||
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
|
||||
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
|
||||
@@ -184,8 +202,8 @@ lark-cli approval instances create \
|
||||
|---|---|
|
||||
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
|
||||
| 已经拿到 `approval_code` | 直接 `approvals.get` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances create` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
@@ -194,3 +212,13 @@ lark-cli approval instances create \
|
||||
- `approval_name`
|
||||
- `instance_code`
|
||||
- `instance_link`
|
||||
|
||||
建议整理为下面这种结构:
|
||||
|
||||
```text
|
||||
审批已创建成功:
|
||||
|
||||
- approval_name: 请假申请
|
||||
- instance_code: 19EAC829-F1CB-527F-BE2A-1330422E60C0
|
||||
- instance_link: https://...
|
||||
```
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
阅读顺序固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create`
|
||||
1. [`lark-approval-initiate.md`](./lark-approval-initiate.md) 中的创建请求参数、节点参数和返回结果说明
|
||||
2. `approval approvals get` 返回的 `form` / `node_list`
|
||||
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
|
||||
4. 本文
|
||||
|
||||
## 总原则
|
||||
|
||||
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
|
||||
- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。
|
||||
- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
|
||||
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
|
||||
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
|
||||
# approval instances cancel
|
||||
|
||||
撤回一个已发起的审批实例(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后再执行撤回。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要撤回该审批实例且目标实例无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval instances cancel \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 撤回一个审批实例
|
||||
lark-cli approval instances cancel \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体
|
||||
lark-cli approval instances cancel \
|
||||
--data @./cancel-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例撤回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
如果你要找“我发起的审批实例”,可先查询已发起列表:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
```
|
||||
|
||||
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instances[].instance_code` | 审批实例 Code;撤回时必须提供 |
|
||||
| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为撤回输入 |
|
||||
| `tasks[].instance_status` | 审批实例状态;可用于判断是否仍处于可撤回阶段 |
|
||||
|
||||
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **撤回的是审批实例,不是单个任务**:`instances cancel` 只需要 `instance_code`,不需要 `task_id`。
|
||||
- **优先确认实例是否仍可撤回**:已经通过、已拒绝、已撤销或已终止的实例通常不适合继续撤回。
|
||||
- **优先从 `instances initiated` 获取目标实例**:因为撤回通常针对“我发起的审批”,这个入口最直接。
|
||||
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个待办/已办上下文进入时,这样更方便。
|
||||
- **先 `--dry-run` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。
|
||||
105
skills/lark-approval/references/lark-approval-instances-cc.md
Normal file
105
skills/lark-approval/references/lark-approval-instances-cc.md
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
# approval instances cc
|
||||
|
||||
给一个审批实例追加抄送人(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后,再提供抄送人的用户 ID 执行抄送。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要抄送该审批实例且目标实例、抄送对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给项目 owner 了解进展"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 按 open_id 抄送一个人
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx"],"comment":"抄送给你知悉"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 一次抄送多个人
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["ou_xxx","ou_yyy"],"comment":"请相关同学同步关注"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 按 user_id 抄送
|
||||
lark-cli approval instances cc \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["123456789"],"comment":"抄送给财务负责人"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体
|
||||
lark-cli approval instances cc \
|
||||
--data @./cc-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 |
|
||||
| `cc_user_ids` | 是 | 抄送人的用户 ID 数组;需要和 `user_id_type` 保持一致 |
|
||||
| `comment` | 否 | 抄送留言,例如 `抄送给你知悉`、`请同步关注该审批进展` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `cc_user_ids` 内用户 ID 的类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认抄送人的 ID 类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例抄送通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
如果你要找“我发起的审批实例”,可先查询已发起列表:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
```
|
||||
|
||||
如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instances[].instance_code` | 审批实例 Code;抄送时必须提供 |
|
||||
| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为抄送输入 |
|
||||
| `tasks[].title` | 任务标题,可用于确认是否是要操作的那个审批 |
|
||||
| `tasks[].instance_status` | 审批实例状态;可用于判断当前审批是否仍处于进行中 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行抄送。
|
||||
|
||||
如需先确认审批表单、当前节点、流转状态,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **抄送的是审批实例,不是单个任务**:`instances cc` 只需要 `instance_code`,不需要 `task_id`。
|
||||
- **`cc_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **`cc_user_ids` 是数组**:即使只抄送一个人,也要按数组形式传入。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **优先从 `instances initiated` 获取目标实例**:因为抄送常见于“我发起的审批”场景,这个入口最直接。
|
||||
- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个审批上下文进入时,这样更方便。
|
||||
- **`comment` 建议简洁明确**:例如 `抄送给你知悉`、`请同步关注审批进展`。避免过长或模糊描述。
|
||||
- **先 `--dry-run` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。
|
||||
145
skills/lark-approval/references/lark-approval-instances-get.md
Normal file
145
skills/lark-approval/references/lark-approval-instances-get.md
Normal file
@@ -0,0 +1,145 @@
|
||||
|
||||
# approval instances get
|
||||
|
||||
获取单个审批实例详情(用户级只读操作)。适合在执行 approve / reject / transfer / rollback / cancel / cc / remind 之前,先查看审批表单、当前节点、任务列表、审批动态和整体状态。
|
||||
|
||||
需要的 scopes: ["approval:instance:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按实例 Code 查询详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览顶层字段
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code |
|
||||
| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批实例详情查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 常见输入来源
|
||||
|
||||
如果你已经有实例 Code,可直接查询:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
如果你还没有实例 Code,可先从以下命令获取:
|
||||
|
||||
```bash
|
||||
# 查询我发起的审批实例
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
|
||||
# 或从任务列表里拿到关联实例 Code
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `instance_code` | 审批实例 Code |
|
||||
| `serial_number` | 审批单编号 |
|
||||
| `definition_code` | 审批定义 Code |
|
||||
| `definition_name` | 审批名称 |
|
||||
| `user_id` | 发起审批的用户 ID |
|
||||
| `department_id` | 发起人所在部门 ID |
|
||||
| `status` | 审批实例状态,见下方“status 枚举” |
|
||||
| `reverted` | 单据是否已被撤销 |
|
||||
| `start_time` | 审批创建时间 |
|
||||
| `end_time` | 审批完成时间,未完成时通常为 `0` |
|
||||
| `form` | 表单数据,JSON 字符串 |
|
||||
| `current_nodes` | 当前审批节点列表 |
|
||||
| `tasks` | 审批任务列表 |
|
||||
| `operation_records` | 审批动态,例如通过、拒绝、转交、加签、回退、撤回、抄送 |
|
||||
| `comments` | 评论列表 |
|
||||
|
||||
## status 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `PENDING` | 审批中 |
|
||||
| `APPROVED` | 已通过 |
|
||||
| `REJECTED` | 已拒绝 |
|
||||
| `CANCELED` | 已撤回 |
|
||||
| `DELETED` | 已删除 |
|
||||
|
||||
## current_nodes 重点字段
|
||||
|
||||
`current_nodes` 常用于判断审批流当前卡在哪一层:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------------------------------------------|
|
||||
| `current_nodes[].node_id` | 当前审批节点 ID |
|
||||
| `current_nodes[].node_name` | 当前审批节点名称 |
|
||||
| `current_nodes[].type` | 审批方式:`AND` 会签、`OR` 或签、`SEQUENTIAL` 依次审批等 |
|
||||
| `current_nodes[].approvers[].task_id` | 当前审批人关联任务 ID |
|
||||
| `current_nodes[].approvers[].user_id` | 当前审批人用户 ID |
|
||||
|
||||
## tasks 重点字段
|
||||
|
||||
`tasks` 常用于把实例和具体审批任务关联起来:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].id` | 审批任务 ID |
|
||||
| `tasks[].node_id` | 任务所属节点 ID |
|
||||
| `tasks[].node_name` | 任务所属节点名称 |
|
||||
| `tasks[].user_id` | 审批人用户 ID |
|
||||
| `tasks[].status` | 任务状态:`PENDING`、`APPROVED`、`REJECTED`、`TRANSFERRED`、`DONE` |
|
||||
| `tasks[].start_time` | 任务开始时间 |
|
||||
| `tasks[].end_time` | 任务完成时间 |
|
||||
|
||||
## operation_records 重点字段
|
||||
|
||||
`operation_records` 常用于审计审批过程:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `operation_records[].type` | 事件类型,如 `PASS`、`REJECT`、`TRANSFER`、`ROLLBACK`、`CANCEL`、`CC` |
|
||||
| `operation_records[].create_time` | 事件发生时间 |
|
||||
| `operation_records[].user_id` | 触发该事件的用户 ID |
|
||||
| `operation_records[].task_id` | 关联任务 ID |
|
||||
| `operation_records[].node_id` | 关联节点 ID |
|
||||
| `operation_records[].comment` | 理由 / 备注 |
|
||||
| `operation_records[].cc_user_ids` | 被抄送人列表(抄送事件时) |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是最适合做“详情确认”的只读命令**:当你已经拿到 `instance_code`,需要确认表单、当前节点、任务状态、审批动态时,优先使用它。
|
||||
- **在执行写操作前先看详情**:例如做 `tasks rollback` 前确认可退回节点,做 `instances cancel` 前确认实例状态,做 `tasks remind` 前确认当前任务是否仍待处理。
|
||||
- **`form` 是 JSON 字符串**:调用方通常还需要再解析一层,才能拿到表单字段值。
|
||||
- **`current_nodes` 和 `tasks` 可以联动看**:前者看“当前卡在哪个节点”,后者看“每个任务目前由谁处理、状态如何”。
|
||||
- **`operation_records` 适合做时间线回溯**:例如排查谁转交过、谁加签过、什么时候撤回或抄送过。
|
||||
- **优先显式传 `locale` 和 `user_id_type`**:这样 agent 更容易理解返回文本和 ID 语义,减少歧义。
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
读取详情后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 同意审批任务
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>"}' --as user --yes
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
|
||||
# 催办审批任务
|
||||
lark-cli approval tasks remind --data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"]}' --as user --yes
|
||||
```
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
# approval instances initiated
|
||||
|
||||
查询当前用户已发起的审批实例列表(用户级只读操作)。适合在需要查看“我发起了哪些审批”、筛选某类审批定义、获取 `instance_code` 供后续 `instances get` / `instances cancel` / `instances cc` 等命令使用时调用。
|
||||
|
||||
需要的 scopes: ["approval:instance:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询我发起的审批列表
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
|
||||
# 只看某个审批定义下我发起的实例
|
||||
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
|
||||
|
||||
# 预览 API 调用,不执行
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --as user --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;已发起审批列表查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `count` | 列表计数,只在第一页返回;大于等于 100 个实例时返回 `99` |
|
||||
| `has_more` | 是否还有更多数据 |
|
||||
| `page_token` | 下一页翻页 Token |
|
||||
| `instances[].instance_code` | 审批实例 Code;后续查询详情或执行撤回 / 抄送时通常需要 |
|
||||
| `instances[].definition_code` | 审批定义 Code |
|
||||
| `instances[].definition_name` | 审批定义名称 |
|
||||
| `instances[].definition_group_id` | 审批定义分组 ID |
|
||||
| `instances[].definition_group_name` | 审批定义分组名称 |
|
||||
| `instances[].initiator` | 发起人 ID |
|
||||
| `instances[].initiator_name` | 发起人姓名 |
|
||||
| `instances[].instance_status` | 审批实例状态,见下方“instance_status 枚举” |
|
||||
| `instances[].instance_external_id` | 第三方审批实例 ID(仅第三方审批实例存在) |
|
||||
| `instances[].link` | 三方审批跳转链接 |
|
||||
| `instances[].summaries` | 摘要字段列表 |
|
||||
|
||||
## instance_status 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `0` | 无流程状态,不展示对应标签 |
|
||||
| `1` | 流程实例流转中 |
|
||||
| `2` | 已通过 |
|
||||
| `3` | 已拒绝 |
|
||||
| `4` | 已撤销 |
|
||||
| `5` | 已终止 |
|
||||
|
||||
## 常见使用场景
|
||||
|
||||
### 1) 找到我要操作的审批实例
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user
|
||||
```
|
||||
|
||||
拿到 `instances[].instance_code` 后,可继续:
|
||||
|
||||
```bash
|
||||
# 查看审批实例详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
```
|
||||
|
||||
### 2) 只看某类审批
|
||||
|
||||
```bash
|
||||
lark-cli approval instances initiated \
|
||||
--params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' \
|
||||
--as user
|
||||
```
|
||||
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
||||
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
||||
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
||||
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
||||
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
||||
- **摘要字段 `summaries` 很适合做列表预览**:当审批标题不够明确时,可结合摘要值帮助识别目标实例。
|
||||
|
||||
## 输出与后续操作
|
||||
|
||||
拿到列表后,常见下一步:
|
||||
|
||||
```bash
|
||||
# 查看单个审批实例详情
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
|
||||
# 撤回审批实例
|
||||
lark-cli approval instances cancel --data '{"instance_code":"<INSTANCE_CODE>"}' --as user --yes
|
||||
|
||||
# 给审批实例追加抄送人
|
||||
lark-cli approval instances cc --data '{"instance_code":"<INSTANCE_CODE>","cc_user_ids":["<USER_ID>"]}' --params '{"user_id_type":"open_id"}' --as user --yes
|
||||
```
|
||||
120
skills/lark-approval/references/lark-approval-tasks-add-sign.md
Normal file
120
skills/lark-approval/references/lark-approval-tasks-add-sign.md
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
# approval tasks add_sign
|
||||
|
||||
给一个审批任务加签(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被加签人的用户 ID、加签方式等参数执行加签。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要对该审批任务加签且目标任务、加签对象、加签方式都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"前加签给财务复核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 前加签(需要 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"请先补充审核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 后加签(需要 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":2,"add_sign_user_ids":["ou_xxx","ou_yyy"],"approval_method":2,"comment":"当前审批完成后请两位继续审核"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 并加签(常见场景可不传 approval_method)
|
||||
lark-cli approval tasks add_sign \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","add_sign_type":3,"add_sign_user_ids":["123456789"],"comment":"并加签给项目 owner"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或较多加签人
|
||||
lark-cli approval tasks add_sign \
|
||||
--data @./add-sign-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `add_sign_type` | 是 | 加签类型:`1` 前加签、`2` 后加签、`3` 并加签 |
|
||||
| `add_sign_user_ids` | 是 | 被加签人 ID 数组;需要和 `user_id_type` 保持一致 |
|
||||
| `approval_method` | 否 | 审批方式:`1` 或签、`2` 会签、`3` 依次审批;**仅在前加签、后加签时需要填写** |
|
||||
| `comment` | 否 | 审批意见或加签说明,例如 `前加签给财务复核`、`请项目 owner 一并确认` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `add_sign_user_ids` 内用户 ID 的类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认被加签人的 ID 类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批加签通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 枚举说明
|
||||
|
||||
### add_sign_type
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `1` | 前加签 |
|
||||
| `2` | 后加签 |
|
||||
| `3` | 并加签 |
|
||||
|
||||
### approval_method
|
||||
|
||||
| 值 | 含义 | 适用场景 |
|
||||
|----|------|----------|
|
||||
| `1` | 或签 | 前加签 / 后加签 |
|
||||
| `2` | 会签 | 前加签 / 后加签 |
|
||||
| `3` | 依次审批 | 前加签 / 后加签 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback / add_sign 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;加签前建议先检查 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行加签。
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行加签操作。
|
||||
- **`add_sign_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **`add_sign_type` 要和业务意图一致**:前加签是在当前审批前插入审批人,后加签是在当前审批后追加审批人,并加签则是增加并行审批人。
|
||||
- **前加签 / 后加签要补 `approval_method`**:不要遗漏,否则请求可能无法准确表达审批方式。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 add_sign 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,加签前应谨慎验证。
|
||||
- **`comment` 建议写明加签原因**:例如 `增加财务复核`、`增加项目 owner 并行确认`,方便相关人员理解上下文。
|
||||
- **先 `--dry-run` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
# approval tasks approve
|
||||
|
||||
同意一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行同意。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确同意审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 同意审批任务,并附带审批意见
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 需要回填表单时,传入 form(按当前命令定义,form 为字符串化 JSON)
|
||||
lark-cli approval tasks approve \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"同意并补充信息","form":"[{\"id\":\"user_name\",\"type\":\"input\",\"value\":\"Alice\"}]"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment / form
|
||||
lark-cli approval tasks approve \
|
||||
--data @./approve-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `comment` | 否 | 审批意见,例如 `同意`、`已确认` |
|
||||
| `form` | 否 | 表单数据;按当前命令定义,字段类型为 `string`,通常传字符串化 JSON;仅在审批动作需要同时回填表单时使用 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批同意通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的两个字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行同意操作。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 approve 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。
|
||||
- **`comment` 建议简洁明确**:例如 `同意`、`同意,信息已核对`。没有审批意见要求时可省略。
|
||||
- **`form` 只在确有需要时传**:大多数简单同意场景只传 `instance_code`、`task_id`、可选 `comment` 即可。
|
||||
- **先 `--dry-run` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。
|
||||
76
skills/lark-approval/references/lark-approval-tasks-query.md
Normal file
76
skills/lark-approval/references/lark-approval-tasks-query.md
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
# approval tasks query
|
||||
|
||||
查询当前用户的审批任务列表,可用于查看待办、已办、知会等分组。只读操作,不会修改审批状态。
|
||||
|
||||
需要的 scopes: ["approval:task:read"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询待办审批
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
|
||||
# 查询已办审批
|
||||
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
||||
|
||||
# 表格格式输出,便于快速浏览
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批任务查询通常应使用用户身份 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## topic 枚举
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `1` | 待办审批 |
|
||||
| `2` | 已办审批 |
|
||||
| `17` | 未读知会 |
|
||||
| `18` | 已读知会 |
|
||||
|
||||
## 输出重点字段
|
||||
|
||||
返回结果中常见字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `count` | 列表计数,只在第一页返回;当任务数大于等于 100 时返回 `99` |
|
||||
| `has_more` | 是否还有更多数据 |
|
||||
| `page_token` | 下一页翻页 Token |
|
||||
| `tasks[].task_id` | 任务 ID,全局唯一 |
|
||||
| `tasks[].instance_code` | 审批实例 Code;后续执行 approve / reject / rollback 等操作时通常需要与 `task_id` 成对使用 |
|
||||
| `tasks[].title` | 任务标题 |
|
||||
| `tasks[].status` | 任务状态:`1` 待办、`2` 已办、`17` 未读、`18` 已读、`33` 处理中、`34` 撤回 |
|
||||
| `tasks[].topic` | 任务所属分组主题 |
|
||||
| `tasks[].instance_status` | 审批实例状态:`0` 无状态、`1` 流转中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 已终止 |
|
||||
| `tasks[].definition_code` | 审批定义 Code |
|
||||
| `tasks[].definition_name` | 审批定义名称 |
|
||||
| `tasks[].initiator` | 发起人 ID |
|
||||
| `tasks[].initiator_name` | 发起人姓名 |
|
||||
| `tasks[].summaries` | 表单摘要字段列表 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
||||
| `tasks[].user_id` | 任务所属用户 ID |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
||||
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
||||
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
||||
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
# approval tasks reject
|
||||
|
||||
拒绝一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行拒绝。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要拒绝该审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks reject \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 拒绝审批任务,并附带审批意见
|
||||
lark-cli approval tasks reject \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","comment":"拒绝,信息不完整"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment
|
||||
lark-cli approval tasks reject \
|
||||
--data @./reject-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `comment` | 否 | 审批意见,例如 `拒绝`、`拒绝,信息不完整` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批拒绝通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的两个字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行拒绝操作。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 reject 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。
|
||||
- **`comment` 建议写清拒绝原因**:例如 `拒绝,缺少合同附件`、`拒绝,预算字段填写不完整`。这有助于发起人理解原因并补充材料。
|
||||
- **先 `--dry-run` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
# approval tasks remind
|
||||
|
||||
对审批实例中的指定任务发起催办(用户级写操作)。通常先通过 `tasks query` 找到待办任务,拿到 `instance_code` 和要催办的 `task_ids`,必要时再用 `instances get` 查看详情,然后执行催办。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要催办该审批且目标实例、目标任务都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:instance:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快处理"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 催办单个审批任务
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID>"],"comment":"请尽快审批该单据"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 同一实例下催办多个任务
|
||||
lark-cli approval tasks remind \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_ids":["<TASK_ID_1>","<TASK_ID_2>"],"comment":"请相关审批人尽快处理"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或多个 task_ids
|
||||
lark-cli approval tasks remind \
|
||||
--data @./remind-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances get` 获取 |
|
||||
| `task_ids` | 是 | 被催办的任务 ID 数组;应与 `instance_code` 属于同一审批实例 |
|
||||
| `comment` | 否 | 催办说明,例如 `请尽快处理`、`该单据较急,请优先审批` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批催办通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;催办时必须提供 |
|
||||
| `tasks[].task_id` | 审批任务 ID;放入 `task_ids` 数组中 |
|
||||
| `tasks[].title` | 任务标题,可用于确认催办对象是否正确 |
|
||||
| `tasks[].status` | 任务状态;一般优先催办仍处于待处理状态的任务 |
|
||||
|
||||
如需进一步确认当前审批流、节点和人员信息,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_ids` 要对应同一个审批实例**:不要把不同实例下的任务 ID 混在同一次催办请求中。
|
||||
- **`task_ids` 是数组**:即使只催办一个任务,也要按数组形式传入。
|
||||
- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 remind 的输入来源。
|
||||
- **催办前先确认任务仍需处理**:已经审批完成、已撤回或已终止的任务一般不适合继续催办。
|
||||
- **`comment` 建议简洁且明确**:例如 `该单据较急,请优先审批`、`请今天内处理`。避免过长或模糊描述。
|
||||
- **先 `--dry-run` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
# approval tasks rollback
|
||||
|
||||
将一个审批任务退回到指定节点(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,再结合实例详情确认可退回的目标节点 `node_ids`,最后执行退回。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要退回该审批且目标任务、退回节点都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"退回补充材料"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 退回到单个节点
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID>"],"comment":"请补充附件后重新提交"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment 或较多 node_ids
|
||||
lark-cli approval tasks rollback \
|
||||
--data @./rollback-body.json \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;退回前建议先检查 |
|
||||
|
||||
如需确认流程节点、当前进度和可退回位置,可先查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
|
||||
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
||||
- **`comment` 建议写清退回原因**:例如 `附件缺失,请补齐后重新提交`、`费用说明不完整,请补充明细`,方便发起人或上一步处理人理解原因。
|
||||
- **先 `--dry-run` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
# approval tasks transfer
|
||||
|
||||
转交一个审批任务给其他用户处理(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被转交人的用户 ID 执行转交。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要转交该审批且目标任务、转交对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。
|
||||
|
||||
需要的 scopes: ["approval:task:write"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先预览请求,不实际执行
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"请你继续处理"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--dry-run
|
||||
|
||||
# 按 open_id 转交审批任务
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"ou_xxx","comment":"转交给你处理"}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 按 user_id 转交审批任务
|
||||
lark-cli approval tasks transfer \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","transfer_user_id":"123456789","comment":"请补充审核"}' \
|
||||
--params '{"user_id_type":"user_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 通过文件传入请求体,适合较长 comment
|
||||
lark-cli approval tasks transfer \
|
||||
--data @./transfer-body.json \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `transfer_user_id` | 是 | 被转交人的用户 ID;需要和 `user_id_type` 保持一致 |
|
||||
| `comment` | 否 | 审批意见或转交说明,例如 `转交给你处理`、`请继续审核该单据` |
|
||||
| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `transfer_user_id` 的 ID 类型 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认 `transfer_user_id` 的真实类型 |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批转交通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 典型前置步骤
|
||||
|
||||
先查到待办任务:
|
||||
|
||||
```bash
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
```
|
||||
|
||||
常用到的字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 |
|
||||
| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;转交前建议先检查 |
|
||||
|
||||
如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行转交。
|
||||
|
||||
如需先确认表单、节点、审批流进度,可继续查看实例详情:
|
||||
|
||||
```bash
|
||||
lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' --as user
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行转交操作。
|
||||
- **`transfer_user_id` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。
|
||||
- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 transfer 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行同意/拒绝等处理动作,转交前也应谨慎验证。
|
||||
- **`comment` 建议写明转交原因**:例如 `你更熟悉该项目,请继续处理`、`转交给预算 owner 审核`,方便接收人理解上下文。
|
||||
- **先 `--dry-run` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。
|
||||
@@ -45,13 +45,15 @@ lark-cli calendar +agenda --as user
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id` |
|
||||
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||
- **重复性日程例外(Exception)**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
|
||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||
@@ -71,6 +73,7 @@ lark-cli calendar +agenda --as user
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -72,9 +73,16 @@ lark-cli calendar events create \
|
||||
lark-cli schema calendar.event.attendees.create
|
||||
## 添加参会人
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||
|
||||
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||
lark-cli calendar event.attendees create \
|
||||
--as user \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||
|
||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.delete
|
||||
|
||||
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) — 认证和全局参数
|
||||
@@ -43,7 +43,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`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||
@@ -65,7 +65,7 @@ lark-cli calendar +update \
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
@@ -39,12 +39,14 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
@@ -68,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -60,6 +60,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
| ------------------- | -- |---------------------------------------------|
|
||||
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
|
||||
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
|
||||
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
|
||||
@@ -71,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# slides history(历史版本与回滚)
|
||||
# docs history(历史版本与回滚)
|
||||
|
||||
用于查看 Slides XML presentation 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
|
||||
## 安全流程
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||
6. 回滚完成后,用 `slides +xml-get` 或 `slides xml_presentations get` 读取演示文稿确认内容。
|
||||
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
|
||||
|
||||
## 按 revision_id 或时间点回滚
|
||||
|
||||
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||
|
||||
1. 执行 `slides +history-list --presentation <presentation>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||
5. 执行 `slides +history-revert --presentation <presentation> --history-version-id <history_version_id>`。
|
||||
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`。
|
||||
|
||||
候选确认时使用类似格式:
|
||||
|
||||
@@ -33,32 +33,32 @@
|
||||
|
||||
```bash
|
||||
# 列出历史版本
|
||||
lark-cli slides +history-list --presentation "<slides_url_or_token>" --page-size 20
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
|
||||
|
||||
# 翻页
|
||||
lark-cli slides +history-list --presentation "<slides_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
|
||||
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||
lark-cli slides +history-revert --presentation "<slides_url_or_token>" --history-version-id 42
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
|
||||
|
||||
# 只发起任务,不等待
|
||||
lark-cli slides +history-revert --presentation "<slides_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
|
||||
# 查询回滚任务状态
|
||||
lark-cli slides +history-revert-status --presentation "<slides_url_or_token>" --task-id "<task_id>"
|
||||
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|-|-|-|-|
|
||||
| `+history-list` | `--presentation` | 是 | `xml_presentation_id`、Slides URL,或可解析为 Slides 的 wiki URL |
|
||||
| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||
| `+history-revert` | `--presentation` | 是 | 同一个演示文稿 |
|
||||
| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||
| `+history-revert-status` | `--presentation` | 是 | 同一个演示文稿 |
|
||||
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
|
||||
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||
|
||||
## 返回值要点
|
||||
@@ -105,18 +105,3 @@ lark-cli slides +history-revert-status --presentation "<slides_url_or_token>" --
|
||||
```
|
||||
|
||||
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||
|
||||
## 回滚后验证
|
||||
|
||||
回滚成功后必须读取一次当前内容确认:
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --presentation "<slides_url_or_token>" --output ./presentation.xml
|
||||
```
|
||||
|
||||
如果只需要快速检查返回结构,也可以走 raw OpenAPI:
|
||||
|
||||
```bash
|
||||
lark-cli api get "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>" \
|
||||
--params '{"revision_id":-1}'
|
||||
```
|
||||
113
skills/lark-doc/references/lark-doc-mindnote.md
Normal file
113
skills/lark-doc/references/lark-doc-mindnote.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 飞书思维笔记(Mindnote)
|
||||
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
|
||||
|
||||
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
|
||||
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
|
||||
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先看命令帮助
|
||||
lark-cli mindnotes nodes list --help
|
||||
lark-cli mindnotes nodes create --help
|
||||
|
||||
# 读取节点列表
|
||||
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
|
||||
|
||||
# 创建子节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
|
||||
|
||||
# 更新已有节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
### `mindnotes nodes list`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
|
||||
返回重点:`data.nodes` 中常见字段有 `node_id`、`parent_id`、`texts`、`notes`、`images`、`finish`、`highlight`。
|
||||
|
||||
### `mindnotes nodes create`
|
||||
|
||||
命令参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
| `--data` | 是 | JSON 请求体 |
|
||||
|
||||
请求体字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `client_token` | 否 | 幂等 token,建议写操作传入;推荐使用时间戳或 UUID |
|
||||
| `nodes` | 是 | 待创建或更新的节点数组 |
|
||||
| `nodes[].node_id` | 否 | 节点 ID;传入已有 `node_id` 时表示更新对应节点 |
|
||||
| `nodes[].parent_id` | 否 | 父节点 ID;创建子节点时传入 |
|
||||
| `nodes[].texts` | 否 | 节点正文富文本数组 |
|
||||
| `nodes[].notes` | 否 | 节点备注富文本数组 |
|
||||
| `nodes[].images` | 否 | 节点图片列表 |
|
||||
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
|
||||
| `nodes[].finish` | 否 | 节点完成状态 |
|
||||
|
||||
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
|
||||
|
||||
```json
|
||||
[{"element_type":"text","text":{"content":"节点内容"}}]
|
||||
```
|
||||
|
||||
### 节点图片(`nodes[].images`)
|
||||
|
||||
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
|
||||
|
||||
```bash
|
||||
# 先上传图片,拿到 token
|
||||
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
|
||||
|
||||
# 再把 token 写进节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地图片路径 |
|
||||
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
|
||||
| `--parent-node` | 是 | 传 Mindnote 的 token |
|
||||
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
1. 先判断用户目标是不是“新建一个思维笔记”。
|
||||
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
3. 如果是操作已有思维笔记,先通过 token 类别判断。
|
||||
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`。
|
||||
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`。
|
||||
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`。
|
||||
7. 再执行 `mindnotes nodes create`。
|
||||
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID,避免重试时重复创建或重复更新。
|
||||
|
||||
> [!CAUTION]
|
||||
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
|
||||
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
|
||||
| `--pattern` | 视指令 | 匹配文本(str_replace) |
|
||||
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
|
||||
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
|
||||
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
||||
|
||||
@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
## 统计范围
|
||||
|
||||
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||
|
||||
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
@@ -24,8 +31,8 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
脚本输出 JSON。对用户汇报时默认只读两个核心字段:
|
||||
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词、数字、中文标点;英文标点不计入。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点;空格不计入。
|
||||
- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。
|
||||
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
|
||||
|
||||
其余字段用于排查或解释:
|
||||
|
||||
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 字数遵循校验
|
||||
|
||||
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||
|
||||
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user