mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
Compare commits
26 Commits
fix/wiki-n
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
docs/ref
|
docs/ref
|
||||||
docs/
|
docs/
|
||||||
|
!tests/cli_e2e/docs/
|
||||||
|
!tests/cli_e2e/docs/*.go
|
||||||
|
!tests/cli_e2e/docs/*.md
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.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
|
## [v1.0.62] - 2026-07-01
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1333,6 +1371,8 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.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.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.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
[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
|
--format csv # Comma-separated values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON Output Contract
|
||||||
|
|
||||||
|
With `--format json` (the default), success and error envelopes are distinct.
|
||||||
|
|
||||||
|
Success goes to **stdout**, exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors go to **stderr**, non-zero exit code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--format csv # 逗号分隔值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON 输出契约
|
||||||
|
|
||||||
|
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||||
|
|
||||||
|
成功信封写入 **stdout**,退出码 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
错误信封写入 **stderr**,退出码非 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||||
|
|
||||||
### 分页
|
### 分页
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,13 +20,28 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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) {
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
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"}},
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET"}) // missing path
|
cmd.SetArgs([]string{"GET"}) // missing path
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
fn := cmd.ValidArgsFunction
|
fn := cmd.ValidArgsFunction
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
flag := cmd.Flags().Lookup("as")
|
flag := cmd.Flags().Lookup("as")
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
t.Fatal("expected --as flag to be registered")
|
t.Fatal("expected --as flag to be registered")
|
||||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|||||||
ContentType: "application/octet-stream",
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
// Should return an error
|
// 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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := root.Execute()
|
err := root.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
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,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
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{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
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{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
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{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||||
// It uses the embedded source so completion candidates match what `schema`
|
// It uses the same source as schema execution so completion candidates match
|
||||||
// execution can resolve (both overlay-free).
|
// what `schema` can resolve.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the embedded catalog and renders the
|
// runSchema resolves the path through the schema catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.EmbeddedCatalog()
|
catalog := registry.SchemaCatalog()
|
||||||
|
if len(catalog.Services()) == 0 {
|
||||||
|
// No embedded metadata and the runtime fallback is empty too: offline
|
||||||
|
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||||
|
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||||
|
}
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
Long: `Update lark-cli to the latest version.
|
Long: `Update lark-cli to the latest version.
|
||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
|
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
|
pm := "npm"
|
||||||
|
install := updater.RunNpmInstall
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
pm = "pnpm"
|
||||||
|
install = updater.RunPnpmInstall
|
||||||
|
}
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := updater.RunNpmInstall(latest)
|
npmResult := install(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined),
|
"hint": permissionHint(combined, pm),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined); hint != "" {
|
if hint := permissionHint(combined, pm); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest)
|
hint := verificationFailureHint(updater, latest, pm)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
skillsPM := "npx"
|
||||||
|
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||||
|
skillsPM = "pnpm dlx"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(npmOutput string) string {
|
func permissionHint(pmOutput, pm string) string {
|
||||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
if pm == "pnpm" {
|
||||||
|
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||||
|
}
|
||||||
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
}
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
return "the previous version has been restored"
|
||||||
}
|
}
|
||||||
|
if pm == "pnpm" {
|
||||||
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
|
}
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||||
|
// and fails the test if the npm install path is invoked.
|
||||||
|
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||||
|
t.Helper()
|
||||||
|
origNew := newUpdater
|
||||||
|
newUpdater = func() *selfupdate.Updater {
|
||||||
|
u := selfupdate.New()
|
||||||
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
|
u.PnpmInstallOverride = pnpmFn
|
||||||
|
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||||
|
t.Errorf("npm install must not be called for a pnpm install")
|
||||||
|
return &selfupdate.NpmResult{}
|
||||||
|
}
|
||||||
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||||
|
t.Errorf("expected updated in output, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Human(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "via pnpm") {
|
||||||
|
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||||
|
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
|
t.Errorf("expected success message, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||||
|
)
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error exit")
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||||
|
t.Errorf("expected failure envelope, got: %s", out)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||||
|
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||||
|
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pnpm add -g") {
|
||||||
|
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
t.Errorf("expected success message in stderr, got: %s", out)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||||
|
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||||
|
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||||
|
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||||
|
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||||
|
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
|
||||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied")
|
hint = permissionHint("EACCES: permission denied", "npm")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error"); got != "" {
|
if got := permissionHint("some other error", "npm"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`CategoryPolicy`.
|
||||||
|
|
||||||
|
### Success envelope (stdout)
|
||||||
|
|
||||||
|
For contrast: success responses render to **stdout** as an
|
||||||
|
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"identity": "user",
|
||||||
|
"data": { "guid": "e297d3d0-..." },
|
||||||
|
"meta": { "count": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers must branch on `ok` (or the process exit code). The success
|
||||||
|
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||||
|
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||||
|
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||||
|
will misclassify every successful call as a failure, which is
|
||||||
|
especially dangerous around write commands (e.g. retrying a create that
|
||||||
|
already succeeded).
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
|||||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||||
WithMissingScopes("mail:user_mailbox.message:send").
|
WithMissingScopes("mail:user_mailbox.message:send").
|
||||||
WithIdentity("user").
|
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 {
|
if got.Category != errs.CategoryAuthorization {
|
||||||
t.Errorf("Category = %q, want %q", 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").
|
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||||
WithMissingScopes("calendar:event:create").
|
WithMissingScopes("calendar:event:create").
|
||||||
WithIdentity("user").
|
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)
|
buf, err := json.Marshal(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||||
"log_id": "20260520-0a1b2c3d",
|
"log_id": "20260520-0a1b2c3d",
|
||||||
"identity": "user",
|
"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"},
|
"missing_scopes": []any{"calendar:event:create"},
|
||||||
}
|
}
|
||||||
for k, want := range wantFields {
|
for k, want := range wantFields {
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
|||||||
// space→dot fallback covers domains where the two already coincide.
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
func commandFormResolver(service string) func(string) string {
|
func commandFormResolver(service string) func(string) string {
|
||||||
byForm := map[string]string{}
|
byForm := map[string]string{}
|
||||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||||
if svc.Name != service {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return func(h string) string {
|
return func(h string) string {
|
||||||
h = strings.TrimSpace(h)
|
h = strings.TrimSpace(h)
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -40,8 +38,6 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
return SourceValue + "/" + build.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentTraceValue returns a header-safe value from the
|
|
||||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
|
||||||
// surrounding whitespace, rejects values containing any Unicode
|
|
||||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
|
||||||
// for any invalid or empty value. Callers can use the result
|
|
||||||
// directly in HTTP headers without further sanitisation.
|
|
||||||
func AgentTraceValue() string {
|
|
||||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
|
||||||
if v == "" || len(v) > agentTraceMaxLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, r := range v {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseSecurityHeaders returns headers that every request must carry.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := AgentTraceValue(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AgentTraceValue / HeaderAgentTrace
|
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTraceValue(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " ")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, val)
|
|
||||||
if got := AgentTraceValue(); got != val {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
|
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentNameMaxLen = 128
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func AgentName() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentTrace() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" || len(v) > maxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "claude-code")
|
||||||
|
if got := AgentName(); got != "claude-code" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, " cursor ")
|
||||||
|
if got := AgentName(); got != "cursor" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\x01injected")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||||
|
t.Setenv(CliAgentName, longVal)
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTrace(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTrace(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " ")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(CliAgentTrace, longVal)
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(CliAgentTrace, val)
|
||||||
|
if got := AgentTrace(); got != val {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||||
// depend on internal/core (which would create an import cycle).
|
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||||
|
// coupling this contract to core's brand enum.
|
||||||
type ClassifyContext struct {
|
type ClassifyContext struct {
|
||||||
Brand string // "feishu" | "lark" — drives console_url host
|
Brand string // "feishu" | "lark" — drives console_url host
|
||||||
AppID string // placed in console_url
|
AppID string // placed in console_url
|
||||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||||
// commas in the `q` query parameter so the console can pre-select them.
|
// 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.
|
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
host := "open.feishu.cn"
|
// QueryEscape both values — clientID and scopes both sit in the query
|
||||||
if brand == "lark" {
|
// string, and untrusted content must not be able to inject extra query
|
||||||
host = "open.larksuite.com"
|
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||||
}
|
// open-platform base URL stays a single source of truth.
|
||||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
|
||||||
pathID := url.PathEscape(appID)
|
|
||||||
if len(scopes) == 0 {
|
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 {
|
func intFromAny(v any) int {
|
||||||
|
|||||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
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 {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
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",
|
name: "ampersand in scope smuggles extra param",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope&evil=injected"},
|
scopes: []string{"scope&evil=injected"},
|
||||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||||
denyInURL: []string{"q=scope&evil=injected"},
|
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in scope splits fragment",
|
name: "hash in scope splits fragment",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope#fragment"},
|
scopes: []string{"scope#fragment"},
|
||||||
wantInURL: []string{"q=scope%23fragment"},
|
wantInURL: []string{"scopes=scope%23fragment"},
|
||||||
denyInURL: []string{"q=scope#fragment"},
|
denyInURL: []string{"scopes=scope#fragment"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "question mark in appID prematurely opens query",
|
name: "question mark in appID prematurely opens query",
|
||||||
appID: "good?q=injected",
|
appID: "good?q=injected",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
denyInURL: []string{"clientID=good?q=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in appID truncates URL",
|
name: "hash in appID truncates URL",
|
||||||
appID: "good#fragment",
|
appID: "good#fragment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
wantInURL: []string{"clientID=good%23fragment"},
|
||||||
denyInURL: []string{"/app/good#fragment/auth"},
|
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",
|
appID: "good/extra/segment",
|
||||||
scopes: []string{"docx:document"},
|
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 {
|
if pe.MissingScopes != nil {
|
||||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
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
|
// at the app level — re-authenticating cannot fix it. The hint must
|
||||||
// point to the developer console regardless of caller identity, or
|
// point to the developer console regardless of caller identity, or
|
||||||
// agents will loop on `auth login` forever.
|
// 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", ""} {
|
for _, identity := range []string{"user", "bot", ""} {
|
||||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||||
if !strings.Contains(got, "developer console") {
|
if !strings.Contains(got, "developer console") {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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>"))
|
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||||
}
|
}
|
||||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||||
if isPlaceholderCredentialURL(match) {
|
if isPlaceholderCredentialURL(file, match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||||
}
|
}
|
||||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||||
|
if !warnForPrivateIPv4(file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||||
}
|
}
|
||||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||||
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
|||||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return isExplicitCredentialKey(name)
|
return isExplicitCredentialKey(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
|||||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if authCredentialTokenKey(key) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return resourceTokenPlaceholderValue(value) ||
|
return resourceTokenPlaceholderValue(value) ||
|
||||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||||
isPlaceholderValue(value) ||
|
isPlaceholderValue(value) ||
|
||||||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
|||||||
return stars >= 6 && alnum > 0
|
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 {
|
func authCredentialTokenKey(key string) bool {
|
||||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||||
case "access_token",
|
case "access_token",
|
||||||
|
"api_token",
|
||||||
|
"bot_token",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
|
"secret_token",
|
||||||
"session_token",
|
"session_token",
|
||||||
|
"service_token",
|
||||||
"bearer_token",
|
"bearer_token",
|
||||||
"auth_token",
|
"auth_token",
|
||||||
"authorization_token",
|
"authorization_token",
|
||||||
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
|||||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPlaceholderCredentialURL(raw string) bool {
|
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||||
userInfo, ok := credentialURLUserInfo(raw)
|
userInfo, ok := credentialURLUserInfo(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return credentialURLPasswordPlaceholder(password)
|
return credentialURLPasswordPlaceholder(password) ||
|
||||||
|
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||||
}
|
}
|
||||||
|
|
||||||
func credentialURLPasswordPlaceholder(password string) bool {
|
func credentialURLPasswordPlaceholder(password string) bool {
|
||||||
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
|||||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
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) {
|
func credentialURLUserInfo(raw string) (string, bool) {
|
||||||
schemeIdx := strings.Index(raw, "://")
|
schemeIdx := strings.Index(raw, "://")
|
||||||
if schemeIdx < 0 {
|
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) {
|
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)
|
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||||
if len(benign) != 0 {
|
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) {
|
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||||
for _, item := range got {
|
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>:real-secret@example.invalid/db",
|
||||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||||
"URL=https://<user>:real-secret@example.invalid/path",
|
"URL=https://<user>:real-secret@example.invalid/path",
|
||||||
|
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
var count int
|
var count int
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count != 3 {
|
if count != 4 {
|
||||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
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) {
|
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||||
for _, item := range got {
|
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"))
|
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||||
if !findingRules(got)["public_content_generic_credential"] {
|
for _, item := range got {
|
||||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
if item.Rule == "public_content_generic_credential" {
|
||||||
|
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||||
// and schema lint.
|
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||||
|
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||||
|
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||||
|
// nothing to resolve; the merged view is the only data such binaries have.
|
||||||
|
func SchemaCatalog() apicatalog.Catalog {
|
||||||
|
if len(EmbeddedServicesTyped()) > 0 {
|
||||||
|
return EmbeddedCatalog()
|
||||||
|
}
|
||||||
|
return RuntimeCatalog()
|
||||||
|
}
|
||||||
|
|||||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||||
|
// restores them (with a full state reset) on cleanup.
|
||||||
|
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resetInit()
|
||||||
|
orig := embeddedMetaJSON
|
||||||
|
embeddedMetaJSON = data
|
||||||
|
t.Cleanup(func() {
|
||||||
|
waitBackgroundRefresh()
|
||||||
|
embeddedMetaJSON = orig
|
||||||
|
resetInit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceEmbedded {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("embedded_svc"); !ok {
|
||||||
|
t.Fatal("expected embedded_svc from embedded metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||||
|
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||||
|
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||||
|
// Init seeds via sync fetch.
|
||||||
|
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(testEnvelopeJSON("remote_svc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
testMetaURL = ts.URL
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceRuntime {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("remote_svc"); !ok {
|
||||||
|
t.Fatal("expected remote_svc from runtime fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
// After a CLI upgrade the embedded data can be fresher than an old
|
||||||
|
// cache; an equal/older cache must not shadow it.
|
||||||
|
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||||
|
// marker, tagged with the given top-level data version and brand.
|
||||||
|
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||||
|
t.Helper()
|
||||||
|
cDir := filepath.Join(dir, "cache")
|
||||||
|
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := MergedRegistry{
|
||||||
|
Version: version,
|
||||||
|
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(reg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||||
|
mData, _ := json.Marshal(cm)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||||
|
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||||
|
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||||
|
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||||
|
t.Helper()
|
||||||
|
embedded, _ := json.Marshal(MergedRegistry{
|
||||||
|
Version: embeddedVer,
|
||||||
|
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||||
|
})
|
||||||
|
swapEmbeddedMeta(t, embedded)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||||
|
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||||
|
InitWithBrand(core.BrandFeishu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, ok := ServiceTyped(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service %q not loaded", name)
|
||||||
|
}
|
||||||
|
return svc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "2.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "2.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "not-a-semver")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||||
|
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||||
|
// plugin builds without compiled meta_data.json still get remote data.
|
||||||
|
initWithCache(t, "0.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// testRegistry returns a minimal MergedRegistry with one service.
|
||||||
|
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||||
|
// so cache overlay passes the version gate in InitWithBrand.
|
||||||
func testRegistry(name string) MergedRegistry {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "test-1.0",
|
Version: "1.0.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "test-1.0" {
|
if reg.Version != "1.0.0" {
|
||||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
|||||||
if appID == "" || scope == "" {
|
if appID == "" || scope == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
host := "open.feishu.cn"
|
|
||||||
if brand == core.BrandLark {
|
|
||||||
host = "open.larksuite.com"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||||
host,
|
core.ResolveOpenBaseURL(brand),
|
||||||
url.QueryEscape(appID),
|
url.QueryEscape(appID),
|
||||||
url.QueryEscape(scope),
|
url.QueryEscape(scope),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
|
InstallPnpm
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,22 +54,32 @@ var (
|
|||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
type DetectResult struct {
|
type DetectResult struct {
|
||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
|
PnpmAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
return d.Method == InstallNpm && d.NpmAvailable
|
switch d.Method {
|
||||||
|
case InstallNpm:
|
||||||
|
return d.NpmAvailable
|
||||||
|
case InstallPnpm:
|
||||||
|
return d.PnpmAvailable
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
switch {
|
||||||
|
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
|
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||||
|
return "installed via pnpm, but pnpm is not available in PATH"
|
||||||
}
|
}
|
||||||
return "not installed via npm"
|
return "not installed via npm or pnpm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
PnpmInstallOverride func(version string) *NpmResult
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -101,17 +113,38 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
backupCreated bool
|
||||||
|
|
||||||
|
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||||
|
// binary was installed cannot change during a single process, so caching is
|
||||||
|
// the correct semantics — and it is required for correctness: the update
|
||||||
|
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||||
|
// skills, so a re-detection at skills time could resolve a now-stale
|
||||||
|
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||||
|
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||||
|
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||||
|
// sequential.
|
||||||
|
detectCache *DetectResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Updater with default (real) behavior.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether
|
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||||
// npm is available for auto-update.
|
// owning package manager is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
return u.DetectOverride()
|
||||||
}
|
}
|
||||||
|
if u.detectCache != nil {
|
||||||
|
return *u.detectCache
|
||||||
|
}
|
||||||
|
result := u.detectInstallMethod()
|
||||||
|
u.detectCache = &result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectInstallMethod performs the real (uncached) detection.
|
||||||
|
func (u *Updater) detectInstallMethod() DetectResult {
|
||||||
exe, err := vfs.Executable()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||||
}
|
}
|
||||||
|
_, npmErr := exec.LookPath("npm")
|
||||||
|
_, pnpmErr := exec.LookPath("pnpm")
|
||||||
|
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFromResolved classifies the resolved binary path into an install
|
||||||
|
// method and records package-manager availability. Split out from
|
||||||
|
// DetectInstallMethod so the classification is unit-testable without touching
|
||||||
|
// the filesystem or PATH.
|
||||||
|
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||||
method := InstallManual
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
method = InstallNpm
|
if containsPnpmMarker(resolved) {
|
||||||
}
|
method = InstallPnpm
|
||||||
|
} else {
|
||||||
npmAvailable := false
|
method = InstallNpm
|
||||||
if method == InstallNpm {
|
|
||||||
if _, err := exec.LookPath("npm"); err == nil {
|
|
||||||
npmAvailable = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||||
return DetectResult{
|
switch method {
|
||||||
Method: method,
|
case InstallNpm:
|
||||||
ResolvedPath: resolved,
|
d.NpmAvailable = npmOnPath
|
||||||
NpmAvailable: npmAvailable,
|
case InstallPnpm:
|
||||||
|
d.PnpmAvailable = pnpmOnPath
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||||
|
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||||
|
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||||
|
// whose resolved path runs through pnpm's home directory (e.g.
|
||||||
|
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||||
|
// followed by "store". Matching only these two shapes (rather than any bare
|
||||||
|
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||||
|
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||||
|
// classification is OS-independent and unit-testable anywhere.
|
||||||
|
func containsPnpmMarker(p string) bool {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == ".pnpm" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||||
|
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||||
|
if u.PnpmInstallOverride != nil {
|
||||||
|
return u.PnpmInstallOverride(version)
|
||||||
|
}
|
||||||
|
r := &NpmResult{}
|
||||||
|
pnpmPath, err := exec.LookPath("pnpm")
|
||||||
|
if err != nil {
|
||||||
|
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||||
|
cmd.Stdout = &r.Stdout
|
||||||
|
cmd.Stderr = &r.Stderr
|
||||||
|
r.Err = cmd.Run()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
return u.runSkillsCommand(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||||
|
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||||
|
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||||
|
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||||
|
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||||
|
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||||
|
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||||
|
// launcher selection is unit-testable on any platform.
|
||||||
|
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||||
|
if method == InstallPnpm && pnpmAvailable {
|
||||||
|
r := args
|
||||||
|
if len(r) > 0 && r[0] == "-y" {
|
||||||
|
r = r[1:]
|
||||||
|
}
|
||||||
|
return "pnpm", append([]string{"dlx"}, r...)
|
||||||
|
}
|
||||||
|
return "npx", args
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
npxPath, err := exec.LookPath("npx")
|
det := u.DetectInstallMethod()
|
||||||
|
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||||
|
binPath, err := exec.LookPath(launcher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainsPnpmMarker(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Classic virtual-store layout (.pnpm segment).
|
||||||
|
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||||
|
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||||
|
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||||
|
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/usr/local/bin/lark-cli", false},
|
||||||
|
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||||
|
// "pnpm" immediately followed by "store".
|
||||||
|
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||||
|
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||||
|
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsPnpmMarker(c.path); got != c.want {
|
||||||
|
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||||
|
u := &Updater{DetectOverride: nil}
|
||||||
|
u.DetectOverride = func() DetectResult {
|
||||||
|
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||||
|
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||||
|
}
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm {
|
||||||
|
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||||
|
}
|
||||||
|
if !got.PnpmAvailable {
|
||||||
|
t.Errorf("PnpmAvailable = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||||
|
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||||
|
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||||
|
}
|
||||||
|
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||||
|
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||||
|
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm available should CanAutoUpdate")
|
||||||
|
}
|
||||||
|
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualReason_Pnpm(t *testing.T) {
|
||||||
|
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||||
|
t.Errorf("pnpm reason = %q", got)
|
||||||
|
}
|
||||||
|
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||||
|
t.Errorf("manual reason = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||||
|
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
got := u.RunPnpmInstall("2.0.0")
|
||||||
|
if got.Err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", got.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||||
|
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||||
|
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInvocation(t *testing.T) {
|
||||||
|
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method InstallMethod
|
||||||
|
pnpmAvailable bool
|
||||||
|
args []string
|
||||||
|
wantLauncher string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||||
|
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||||
|
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||||
|
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||||
|
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||||
|
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||||
|
if gotLauncher != c.wantLauncher {
|
||||||
|
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||||
|
}
|
||||||
|
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||||
|
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||||
|
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||||
|
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||||
|
// stays consistent with what was detected — and reported — before the update.
|
||||||
|
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||||
|
u := New()
|
||||||
|
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||||
|
u.detectCache = &cached
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||||
|
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
fetchTimeout = 5 * time.Second
|
fetchTimeout = 15 * time.Second
|
||||||
stateFile = "update-state.json"
|
stateFile = "update-state.json"
|
||||||
maxBody = 256 << 10 // 256 KB
|
maxBody = 256 << 10 // 256 KB
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.63",
|
"version": "1.0.65",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
|||||||
return attendees, nil
|
return attendees, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||||
|
for _, attendee := range attendees {
|
||||||
|
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
||||||
|
if err == nil || !attendeesIncludeRoom(attendees) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
||||||
|
}
|
||||||
|
|
||||||
var CalendarCreate = common.Shortcut{
|
var CalendarCreate = common.Shortcut{
|
||||||
Service: "calendar",
|
Service: "calendar",
|
||||||
Command: "+create",
|
Command: "+create",
|
||||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"need_notification": true,
|
"need_notification": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = guideApprovalRoomReasonError(err, attendees)
|
||||||
// Rollback: delete the event
|
// Rollback: delete the event
|
||||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"start": startStr,
|
"start": startStr,
|
||||||
"end": endStr,
|
"end": endStr,
|
||||||
}
|
}
|
||||||
|
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||||
|
resultData["recurrence"] = recurrence
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||||
var rows []map[string]interface{}
|
var rows []map[string]interface{}
|
||||||
|
|||||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||||
|
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||||
|
type calendarEventTime struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventVChat mirrors the vchat block in the API response.
|
||||||
|
type calendarEventVChat struct {
|
||||||
|
VCType string `json:"vc_type,omitempty"`
|
||||||
|
IconType string `json:"icon_type,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
MeetingURL string `json:"meeting_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventLocation mirrors the location block in the API response.
|
||||||
|
type calendarEventLocation struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventReminder mirrors a reminder entry.
|
||||||
|
type calendarEventReminder struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventOrganizer mirrors event_organizer.
|
||||||
|
type calendarEventOrganizer struct {
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventAttachment mirrors a single attachment entry.
|
||||||
|
type calendarEventAttachment struct {
|
||||||
|
FileToken string `json:"file_token,omitempty"`
|
||||||
|
FileSize string `json:"file_size,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||||
|
type calendarEventCheckInTime struct {
|
||||||
|
TimeType string `json:"time_type,omitempty"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckIn mirrors event_check_in.
|
||||||
|
type calendarEventCheckIn struct {
|
||||||
|
EnableCheckIn bool `json:"enable_check_in"`
|
||||||
|
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||||
|
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||||
|
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEvent mirrors the event object inside the API response.
|
||||||
|
type calendarEvent struct {
|
||||||
|
EventID string `json:"event_id,omitempty"`
|
||||||
|
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||||
|
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||||
|
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||||
|
Visibility string `json:"visibility,omitempty"`
|
||||||
|
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||||
|
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||||
|
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||||
|
Location *calendarEventLocation `json:"location,omitempty"`
|
||||||
|
Color int `json:"color,omitempty"`
|
||||||
|
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||||
|
Recurrence string `json:"recurrence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
IsException bool `json:"is_exception,omitempty"`
|
||||||
|
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||||
|
CreateTime string `json:"create_time,omitempty"`
|
||||||
|
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||||
|
AppLink string `json:"app_link,omitempty"`
|
||||||
|
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||||
|
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||||
|
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||||
|
rawEvent, ok := data["event"]
|
||||||
|
if !ok || rawEvent == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rawEvent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var event calendarEvent
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCalendarEventOutput converts the typed event into the output map and
|
||||||
|
// applies the four transformation rules:
|
||||||
|
// 1. create_time -> RFC3339
|
||||||
|
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||||
|
// 3. flatten event into the top-level result
|
||||||
|
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||||
|
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||||
|
raw, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||||
|
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(startMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(endMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||||
|
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||||
|
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||||
|
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, _ := out["status"].(string); status != "cancelled" {
|
||||||
|
delete(out, "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarGet gets a single calendar event detail.
|
||||||
|
var CalendarGet = common.Shortcut{
|
||||||
|
Service: "calendar",
|
||||||
|
Command: "+get",
|
||||||
|
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"calendar:calendar.event:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||||
|
{Name: "event-id", Desc: "event ID", Required: true},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||||
|
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||||
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
if eventId == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
d := common.NewDryRunAPI()
|
||||||
|
switch calendarId {
|
||||||
|
case "":
|
||||||
|
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||||
|
calendarId = "<primary>"
|
||||||
|
case "primary":
|
||||||
|
calendarId = "<primary>"
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
return d.
|
||||||
|
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||||
|
Set("calendar_id", calendarId).
|
||||||
|
Set("event_id", eventId)
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
if calendarId == "" {
|
||||||
|
calendarId = PrimaryCalendarIDStr
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped("GET",
|
||||||
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||||
|
validate.EncodePathSegment(calendarId),
|
||||||
|
validate.EncodePathSegment(eventId)),
|
||||||
|
nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := parseCalendarEvent(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := buildCalendarEventOutput(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
summary, _ := out["summary"].(string)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "(untitled)"
|
||||||
|
}
|
||||||
|
startMap, _ := out["start_time"].(map[string]interface{})
|
||||||
|
endMap, _ := out["end_time"].(map[string]interface{})
|
||||||
|
startStr, _ := startMap["datetime"].(string)
|
||||||
|
if startStr == "" {
|
||||||
|
startStr, _ = startMap["date"].(string)
|
||||||
|
}
|
||||||
|
endStr, _ := endMap["datetime"].(string)
|
||||||
|
if endStr == "" {
|
||||||
|
endStr, _ = endMap["date"].(string)
|
||||||
|
}
|
||||||
|
eventIdOut, _ := out["event_id"].(string)
|
||||||
|
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||||
|
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||||
|
row := map[string]interface{}{
|
||||||
|
"event_id": eventIdOut,
|
||||||
|
"summary": summary,
|
||||||
|
"start": startStr,
|
||||||
|
"end": endStr,
|
||||||
|
"free_busy_status": freeBusyStatus,
|
||||||
|
"self_rsvp_status": selfRsvpStatus,
|
||||||
|
}
|
||||||
|
output.PrintTable(w, []map[string]interface{}{row})
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_approval_room",
|
||||||
|
"summary": "Approval Room",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/events/evt_approval_room/attendees",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": codeInvalidParamsWithDetail,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"details": []interface{}{
|
||||||
|
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE",
|
||||||
|
URL: "/events/evt_approval_room",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--summary", "Approval Room",
|
||||||
|
"--start", "2025-03-21T00:00:00+08:00",
|
||||||
|
"--end", "2025-03-21T01:00:00+08:00",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--attendee-ids", "omm_room1",
|
||||||
|
"--as", "user",
|
||||||
|
}, f, nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf returned !ok for %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI {
|
||||||
|
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||||
|
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||||
|
}
|
||||||
|
if p.Code != codeInvalidParamsWithDetail {
|
||||||
|
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
||||||
|
if !strings.Contains(p.Hint, want) {
|
||||||
|
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||||
// primary error stays the add failure (classification preserved) and the Hint
|
// primary error stays the add failure (classification preserved) and the Hint
|
||||||
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||||
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
|||||||
// Shortcuts() registration test
|
// Shortcuts() registration test
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestShortcuts_Returns9(t *testing.T) {
|
func TestShortcuts_Returns10(t *testing.T) {
|
||||||
shortcuts := Shortcuts()
|
shortcuts := Shortcuts()
|
||||||
if len(shortcuts) != 9 {
|
if len(shortcuts) != 10 {
|
||||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||||
}
|
}
|
||||||
|
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, s := range shortcuts {
|
for _, s := range shortcuts {
|
||||||
names[s.Command] = true
|
names[s.Command] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("missing shortcut %s", want)
|
t.Errorf("missing shortcut %s", want)
|
||||||
}
|
}
|
||||||
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
|||||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CalendarGet tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_001",
|
||||||
|
"summary": "Daily Sync",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742515200",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"end_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742518800",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_001",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||||
|
if strings.Contains(out, "\"event\": {") {
|
||||||
|
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||||
|
t.Errorf("expected event_id in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// status=confirmed should be dropped
|
||||||
|
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||||
|
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
// timestamp must be replaced with datetime
|
||||||
|
if strings.Contains(out, "\"timestamp\":") {
|
||||||
|
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"datetime\":") {
|
||||||
|
t.Errorf("expected datetime in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||||
|
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||||
|
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_002",
|
||||||
|
"summary": "Cancelled Meeting",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
"status": "cancelled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_002",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||||
|
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_003",
|
||||||
|
"summary": "All-day",
|
||||||
|
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||||
|
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_003",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||||
|
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||||
|
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--event-id", " ",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for empty event-id")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Param != "--event-id" {
|
||||||
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_404",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error when event field is missing")
|
||||||
|
}
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(err, &ie) {
|
||||||
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||||
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
|||||||
CalendarSuggestion,
|
CalendarSuggestion,
|
||||||
CalendarMeeting,
|
CalendarMeeting,
|
||||||
CalendarSearchEvent,
|
CalendarSearchEvent,
|
||||||
|
CalendarGet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
{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-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)", 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)"},
|
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
|||||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||||
case "update":
|
case "update":
|
||||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||||
|
case "history-list", "history-revert", "history-revert-status":
|
||||||
|
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||||
default:
|
default:
|
||||||
return docsSkillReadCommand
|
return docsSkillReadCommand
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
|||||||
return "lark-cli docs +fetch --help"
|
return "lark-cli docs +fetch --help"
|
||||||
case "update":
|
case "update":
|
||||||
return "lark-cli docs +update --help"
|
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:
|
default:
|
||||||
return "lark-cli docs --help"
|
return "lark-cli docs --help"
|
||||||
}
|
}
|
||||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
|||||||
DocsCreate,
|
DocsCreate,
|
||||||
DocsFetch,
|
DocsFetch,
|
||||||
DocsUpdate,
|
DocsUpdate,
|
||||||
|
DocsHistoryList,
|
||||||
|
DocsHistoryRevert,
|
||||||
|
DocsHistoryRevertStatus,
|
||||||
DocMediaInsert,
|
DocMediaInsert,
|
||||||
DocMediaUpload,
|
DocMediaUpload,
|
||||||
DocMediaPreview,
|
DocMediaPreview,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ const (
|
|||||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||||
driveImport800MBFileSizeLimit int64 = 800 * 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
|
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||||
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
|
|||||||
"pptx": {"slides"},
|
"pptx": {"slides"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||||
|
|
||||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||||
type driveImportSpec struct {
|
type driveImportSpec struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
return status, true, nil
|
return status, true, nil
|
||||||
}
|
}
|
||||||
if status.Failed() {
|
if status.Failed() {
|
||||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
return status, false, driveImportFailureError(status)
|
||||||
if msg == "" {
|
|
||||||
msg = status.StatusLabel()
|
|
||||||
}
|
|
||||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hadSuccessfulPoll && lastErr != nil {
|
if !hadSuccessfulPoll && lastErr != nil {
|
||||||
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
|
|
||||||
return lastStatus, false, nil
|
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"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
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: "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"},
|
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
"+chat-create",
|
"+chat-create",
|
||||||
"+chat-list",
|
"+chat-list",
|
||||||
|
"+chat-members-list",
|
||||||
"+chat-messages-list",
|
"+chat-messages-list",
|
||||||
"+chat-search",
|
"+chat-search",
|
||||||
"+chat-update",
|
"+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{
|
return []common.Shortcut{
|
||||||
ImChatCreate,
|
ImChatCreate,
|
||||||
ImChatList,
|
ImChatList,
|
||||||
|
ImChatMembersList,
|
||||||
ImChatMessageList,
|
ImChatMessageList,
|
||||||
ImChatSearch,
|
ImChatSearch,
|
||||||
ImChatUpdate,
|
ImChatUpdate,
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import (
|
|||||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||||
|
|
||||||
// Error codes from the minutes API.
|
// Error codes from the minutes API.
|
||||||
const minutesDetailNoReadPermissionCode = 2091005
|
const (
|
||||||
|
minutesDetailProcessingCode = 2091003
|
||||||
|
minutesDetailNoReadPermissionCode = 2091005
|
||||||
|
minutesDetailWaitTimeoutDefault = 300
|
||||||
|
minutesDetailWaitIntervalDefault = 15
|
||||||
|
)
|
||||||
|
|
||||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||||
|
|
||||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
|||||||
// minuteDetailItem represents a single minute detail result.
|
// minuteDetailItem represents a single minute detail result.
|
||||||
type minuteDetailItem struct {
|
type minuteDetailItem struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
MinuteToken string `json:"minute_token"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
NextCommand string `json:"next_command,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
waitReady := runtime.Bool("wait-ready")
|
||||||
|
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||||
|
|
||||||
|
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||||
|
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||||
} else {
|
} else {
|
||||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
needKeyword := runtime.Bool("keyword")
|
needKeyword := runtime.Bool("keyword")
|
||||||
|
|
||||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||||
|
} else {
|
||||||
|
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
artifacts := make(map[string]any)
|
artifacts := make(map[string]any)
|
||||||
if needSummary {
|
if needSummary {
|
||||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isMinutesDetailProcessingError(err error) bool {
|
||||||
|
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||||
|
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||||
|
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||||
|
}
|
||||||
|
if intervalSeconds <= 0 {
|
||||||
|
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||||
|
}
|
||||||
|
return timeoutSeconds, intervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
data, err := call()
|
||||||
|
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return nil, ctxErr
|
||||||
|
}
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 || interval > remaining {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||||
|
var flags []string
|
||||||
|
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||||
|
if runtime.Bool(flag) {
|
||||||
|
flags = append(flags, "--"+flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||||
|
result.Status = "processing"
|
||||||
|
result.Retryable = true
|
||||||
|
result.Error = reason
|
||||||
|
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||||
|
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||||
|
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||||
|
parts = append(parts, artifactFlags...)
|
||||||
|
parts = append(parts, "--wait-ready")
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
|||||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||||
|
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||||
|
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||||
|
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
|||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||||
if r.Error != "" {
|
if r.Error != "" {
|
||||||
row["status"] = "FAIL"
|
if r.Status == "processing" {
|
||||||
|
row["status"] = "PROCESSING"
|
||||||
|
} else {
|
||||||
|
row["status"] = "FAIL"
|
||||||
|
}
|
||||||
row["error"] = r.Error
|
row["error"] = r.Error
|
||||||
|
if r.NextCommand != "" {
|
||||||
|
row["next_command"] = r.NextCommand
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
row["status"] = "OK"
|
row["status"] = "OK"
|
||||||
row["title"] = r.Title
|
row["title"] = r.Title
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detailProcessingStub(path string) *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: path,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 2091003,
|
||||||
|
"msg": "minute is processing",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesDetail.Mount(parent, f)
|
||||||
|
parent.SetOut(stdout)
|
||||||
|
parent.SetArgs([]string{"+detail", "--help"})
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
if err := parent.Execute(); err != nil {
|
||||||
|
t.Fatalf("help failed: %v", err)
|
||||||
|
}
|
||||||
|
help := stdout.String()
|
||||||
|
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||||
|
if strings.Contains(help, hidden) {
|
||||||
|
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Execute tests with mocked HTTP
|
// Execute tests with mocked HTTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["retryable"] != true {
|
||||||
|
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||||
|
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["title"] != "Ready Metadata" {
|
||||||
|
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||||
|
}
|
||||||
|
if _, ok := m["artifacts"]; ok {
|
||||||
|
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||||
|
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
arts, _ := m["artifacts"].(map[string]any)
|
||||||
|
if arts == nil {
|
||||||
|
t.Fatal("expected artifacts")
|
||||||
|
}
|
||||||
|
if arts["summary"] != "Test summary content" {
|
||||||
|
t.Fatalf("summary = %v", arts["summary"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||||
|
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
var callCount int
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||||
|
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||||
|
Reusable: true,
|
||||||
|
OnMatch: func(req *http.Request) { callCount++ },
|
||||||
|
})
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pure function tests
|
// Pure function tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||||
|
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||||
|
if timeout != 9 || interval != 3 {
|
||||||
|
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||||
|
}
|
||||||
|
data, _ := resp["data"].(map[string]any)
|
||||||
|
minutes, _ := data["minutes"].([]any)
|
||||||
|
if len(minutes) != 1 {
|
||||||
|
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||||
|
}
|
||||||
|
m, _ := minutes[0].(map[string]any)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||||
func chdirForDetailTest(t *testing.T) string {
|
func chdirForDetailTest(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
dr := common.NewDryRunAPI()
|
return common.NewDryRunAPI().
|
||||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
|
||||||
}
|
|
||||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
|
||||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
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"))
|
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||||
|
|
||||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
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))
|
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
|||||||
return body
|
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{}{
|
out := map[string]interface{}{
|
||||||
"minute_token": minuteToken,
|
"minute_token": minuteToken,
|
||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
}
|
}
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
out["from_speaker_id"] = fromSpeakerID
|
out["from_speaker_id"] = fromSpeakerID
|
||||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
|
||||||
out["from_speaker_input"] = fromSpeakerInput
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
out["from_user_id"] = fromUserID
|
out["from_user_id"] = fromUserID
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||||
if fromSpeakerInput != "" {
|
|
||||||
return fromSpeakerInput
|
|
||||||
}
|
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
return fromSpeakerID
|
return fromSpeakerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
||||||
warmTokenCache(t)
|
|
||||||
|
|
||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
|
||||||
"+speaker-replace",
|
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run", "--as", "user",
|
|
||||||
}, f, stdout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
if !strings.Contains(out, "GET") {
|
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
|
||||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "PUT") {
|
|
||||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "ou_new_speaker") {
|
|
||||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
warmTokenCache(t)
|
warmTokenCache(t)
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||||
Method: http.MethodGet,
|
// straight through without a second speakerlist call. If the code still
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"speakers": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
|
||||||
"name": "说话人1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
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{
|
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||||
"+speaker-replace",
|
"+speaker-replace",
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||||
"--from-speaker-id", "说话人1",
|
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||||
"--to-user-id", "ou_new_speaker",
|
"--to-user-id", "ou_new_speaker",
|
||||||
"--format", "json", "--as", "user",
|
"--format", "json", "--as", "user",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
FromSpeakerID string `json:"from_speaker_id"`
|
||||||
FromSpeakerInput string `json:"from_speaker_input"`
|
ToUserID string `json:"to_user_id"`
|
||||||
FromSpeakerID string `json:"from_speaker_id"`
|
|
||||||
ToUserID string `json:"to_user_id"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
t.Fatalf("unmarshal stdout: %v", err)
|
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" {
|
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
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) {
|
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "GET") {
|
if strings.Contains(out, "/transcript/speakerlist") {
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
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") {
|
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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
|||||||
outData := map[string]interface{}{
|
outData := map[string]interface{}{
|
||||||
"minute_url": minuteURL,
|
"minute_url": minuteURL,
|
||||||
}
|
}
|
||||||
|
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||||
|
outData["minute_token"] = minuteToken
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(outData, nil, nil)
|
runtime.OutFormat(outData, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractUploadedMinuteToken(minuteURL string) string {
|
||||||
|
u, err := url.Parse(minuteURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "minutes" && i+1 < len(parts) {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
|||||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||||
}
|
}
|
||||||
|
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||||
|
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||||
|
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||||
|
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||||
|
{name: "invalid", url: "://bad", want: ""},
|
||||||
|
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||||
|
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
|||||||
return objectives, nil
|
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.
|
// createObjective calls the API to create an objective.
|
||||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
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{}{
|
body := map[string]interface{}{
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
|
|||||||
|
|
||||||
// createKR calls the API to create a key result.
|
// createKR calls the API to create a key result.
|
||||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
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{}{
|
body := map[string]interface{}{
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
|
|||||||
|
|
||||||
for i, obj := range objectives {
|
for i, obj := range objectives {
|
||||||
// Objective creation
|
// Objective creation
|
||||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
objContent := BuildContentBlock(obj.Text, obj.Mention)
|
||||||
objBody := map[string]interface{}{
|
objBody := map[string]interface{}{
|
||||||
"content": objContent,
|
"content": objContent,
|
||||||
}
|
}
|
||||||
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
|
|||||||
|
|
||||||
// KR creations
|
// KR creations
|
||||||
for j, kr := range obj.KRs {
|
for j, kr := range obj.KRs {
|
||||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
krContent := BuildContentBlock(kr.Text, kr.Mention)
|
||||||
krBody := map[string]interface{}{
|
krBody := map[string]interface{}{
|
||||||
"content": krContent,
|
"content": krContent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildContentBlock(t *testing.T) {
|
func TestBuildContentBlock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||||
if cb == nil {
|
if cb == nil {
|
||||||
t.Fatal("expected non-nil ContentBlock")
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,10 @@ type RespCategory struct {
|
|||||||
|
|
||||||
// RespCycle 周期
|
// RespCycle 周期
|
||||||
type RespCycle struct {
|
type RespCycle struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreateTime string `json:"create_time"`
|
StartTime string `json:"start_time"`
|
||||||
UpdateTime string `json:"update_time"`
|
EndTime string `json:"end_time"`
|
||||||
TenantCycleID string `json:"tenant_cycle_id"`
|
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespIndicator 指标
|
// RespIndicator 指标
|
||||||
@@ -152,3 +147,145 @@ type RespProgress struct {
|
|||||||
Content *string `json:"content,omitempty"`
|
Content *string `json:"content,omitempty"`
|
||||||
ProgressRate *RespProgressRate `json:"progress_rate,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,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
{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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
cycleID := runtime.Str("cycle-id")
|
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 {
|
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")
|
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
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
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 {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
cycleID := runtime.Str("cycle-id")
|
cycleID := runtime.Str("cycle-id")
|
||||||
|
style := runtime.Str("style")
|
||||||
|
|
||||||
// Paginate objectives under the cycle.
|
// Paginate objectives under the cycle.
|
||||||
queryParams := map[string]interface{}{"page_size": "100"}
|
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.
|
// For each objective, paginate key results and convert to response format.
|
||||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
if style == "simple" {
|
||||||
for i := range objectives {
|
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
|
||||||
if err := ctx.Err(); err != nil {
|
for i := range objectives {
|
||||||
return err
|
|
||||||
}
|
|
||||||
obj := &objectives[i]
|
|
||||||
|
|
||||||
krQuery := map[string]interface{}{"page_size": "100"}
|
|
||||||
|
|
||||||
var keyResults []KeyResult
|
|
||||||
krPage := 0
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if krPage > 0 {
|
obj := &objectives[i]
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
krPage++
|
|
||||||
|
|
||||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsRaw, _ := data["items"].([]interface{})
|
respObj := obj.ToSimple()
|
||||||
for _, item := range itemsRaw {
|
if respObj == nil {
|
||||||
raw, err := json.Marshal(item)
|
continue
|
||||||
if err != 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 {
|
respObj.KeyResults = respKRs
|
||||||
continue
|
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)
|
respObj := obj.ToResp()
|
||||||
if !hasMore || pageToken == "" {
|
if respObj == nil {
|
||||||
break
|
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()
|
result := map[string]interface{}{
|
||||||
if respObj == nil {
|
"cycle_id": cycleID,
|
||||||
continue
|
"objectives": respObjectives,
|
||||||
|
"total": len(respObjectives),
|
||||||
|
"style": style,
|
||||||
}
|
}
|
||||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
|
||||||
for j := range keyResults {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
if r := keyResults[j].ToResp(); r != nil {
|
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||||
respKRs = append(respKRs, *r)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
|||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
cycleStart := time.UnixMilli(startMs)
|
cycleStart := time.UnixMilli(startMs).UTC()
|
||||||
cycleEnd := time.UnixMilli(endMs)
|
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||||
// Two ranges overlap iff one starts before the other ends
|
// Two ranges overlap iff one starts before the other ends
|
||||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
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{
|
var OKRListCycles = common.Shortcut{
|
||||||
Service: "okr",
|
Service: "okr",
|
||||||
Command: "+cycle-list",
|
Command: "+cycle-list",
|
||||||
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
|
|||||||
respCycles = append(respCycles, filtered[i].ToResp())
|
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{}{
|
runtime.OutFormat(map[string]interface{}{
|
||||||
"cycles": respCycles,
|
"cycles": respCycles,
|
||||||
"total": len(respCycles),
|
"total": len(respCycles),
|
||||||
|
"current_active_cycles": currentActiveCycles,
|
||||||
}, nil, func(w io.Writer) {
|
}, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||||
for _, c := range respCycles {
|
for _, c := range respCycles {
|
||||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
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
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
|
|||||||
if len(cycles) != 0 {
|
if len(cycles) != 0 {
|
||||||
t.Fatalf("cycles = %v, want empty", cycles)
|
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) {
|
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
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{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/okr/v2/cycles",
|
URL: "/open-apis/okr/v2/cycles",
|
||||||
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
|||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"id": "cycle-1",
|
"id": "cycle-active",
|
||||||
"start_time": "1735689600000",
|
"start_time": strconv.FormatInt(activeStartMs, 10),
|
||||||
"end_time": "1751318400000",
|
"end_time": strconv.FormatInt(activeEndMs, 10),
|
||||||
"cycle_status": 1,
|
"cycle_status": 1, // normal
|
||||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||||
"tenant_cycle_id": "tc-1",
|
"tenant_cycle_id": "tc-1",
|
||||||
"score": 0.75,
|
"score": 0.75,
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"id": "cycle-2",
|
"id": "cycle-past",
|
||||||
"start_time": "1704067200000",
|
"start_time": strconv.FormatInt(pastStartMs, 10),
|
||||||
"end_time": "1719792000000",
|
"end_time": strconv.FormatInt(pastEndMs, 10),
|
||||||
"cycle_status": 2,
|
"cycle_status": 2, // invalid
|
||||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||||
"tenant_cycle_id": "tc-2",
|
"tenant_cycle_id": "tc-2",
|
||||||
"score": 0.5,
|
"score": 0.5,
|
||||||
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
|||||||
if int(total) != 2 {
|
if int(total) != 2 {
|
||||||
t.Fatalf("total = %v, want 2", total)
|
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) {
|
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resp := &RespCycle{
|
resp := &RespCycle{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
CreateTime: formatTimestamp(c.CreateTime),
|
StartTime: formatTimestamp(c.StartTime),
|
||||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
EndTime: formatTimestamp(c.EndTime),
|
||||||
TenantCycleID: c.TenantCycleID,
|
|
||||||
Owner: *c.Owner.ToResp(),
|
|
||||||
StartTime: formatTimestamp(c.StartTime),
|
|
||||||
EndTime: formatTimestamp(c.EndTime),
|
|
||||||
Score: c.Score,
|
|
||||||
}
|
}
|
||||||
if c.CycleStatus != nil {
|
if c.CycleStatus != nil {
|
||||||
s := c.CycleStatus.ToString()
|
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 进度率
|
// ProgressRateV1 进度率
|
||||||
type ProgressRateV1 struct {
|
type ProgressRateV1 struct {
|
||||||
Percent *float64 `json:"percent,omitempty"`
|
Percent *float64 `json:"percent,omitempty"`
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
|
|||||||
convey.So(resp, convey.ShouldNotBeNil)
|
convey.So(resp, convey.ShouldNotBeNil)
|
||||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
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() {
|
convey.Convey("Objective", func() {
|
||||||
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
|
|||||||
// boolPtr returns a pointer to the given bool value.
|
// boolPtr returns a pointer to the given bool value.
|
||||||
func boolPtr(v bool) *bool { return &v }
|
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.
|
// listTypePtr returns a pointer to the given ListType value.
|
||||||
func listTypePtr(v ListType) *ListType { return &v }
|
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"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
|
|||||||
|
|
||||||
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||||
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
||||||
|
style := runtime.Str("style")
|
||||||
content := runtime.Str("content")
|
content := runtime.Str("content")
|
||||||
var cb ContentBlock
|
var contentV1 *ContentBlockV1
|
||||||
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)
|
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")
|
targetType := runtime.Str("target-type")
|
||||||
targetTypeVal := targetTypeAllowed[targetType]
|
targetTypeVal := targetTypeAllowed[targetType]
|
||||||
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
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-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: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||||
{Name: "progress-percent", Desc: "progress percentage"},
|
{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-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: "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: "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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
content := runtime.Str("content")
|
content := runtime.Str("content")
|
||||||
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
|
||||||
var cb ContentBlock
|
style := runtime.Str("style")
|
||||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
if style != "simple" && style != "richtext" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
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")
|
targetID := runtime.Str("target-id")
|
||||||
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := record.ToResp()
|
style := runtime.Str("style")
|
||||||
result := map[string]interface{}{
|
var result map[string]interface{}
|
||||||
"progress": resp,
|
if style == "simple" {
|
||||||
}
|
resp := record.ToSimple()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
|
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"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 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 ---
|
// --- Validate tests ---
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", "not-json",
|
"--content", "not-json",
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "abc",
|
"--target-id", "abc",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "invalid",
|
"--target-type", "invalid",
|
||||||
})
|
})
|
||||||
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--user-id-type", "invalid",
|
"--user-id-type", "invalid",
|
||||||
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-percent", "999999999999",
|
"--progress-percent", "999999999999",
|
||||||
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-percent", "abc",
|
"--progress-percent", "abc",
|
||||||
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-status", "invalid_status",
|
"--progress-status", "invalid_status",
|
||||||
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-percent", "75",
|
"--progress-percent", "75",
|
||||||
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "456",
|
"--target-id", "456",
|
||||||
"--target-type", "key_result",
|
"--target-type", "key_result",
|
||||||
})
|
})
|
||||||
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "789",
|
"--target-id", "789",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
|||||||
t.Fatal("expected error for API failure")
|
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{
|
Flags: []common.Flag{
|
||||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
{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: "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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
progressID := runtime.Str("progress-id")
|
progressID := runtime.Str("progress-id")
|
||||||
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
|
|||||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
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")
|
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
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
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 {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
progressID := runtime.Str("progress-id")
|
progressID := runtime.Str("progress-id")
|
||||||
userIDType := runtime.Str("user-id-type")
|
userIDType := runtime.Str("user-id-type")
|
||||||
|
style := runtime.Str("style")
|
||||||
|
|
||||||
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
||||||
|
|
||||||
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := record.ToResp()
|
var result map[string]interface{}
|
||||||
result := map[string]interface{}{
|
if style == "simple" {
|
||||||
"progress": resp,
|
resp := record.ToSimple()
|
||||||
}
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
|
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
|
|||||||
|
|
||||||
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||||
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
||||||
|
style := runtime.Str("style")
|
||||||
content := runtime.Str("content")
|
content := runtime.Str("content")
|
||||||
var cb ContentBlock
|
var contentV1 *ContentBlockV1
|
||||||
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)
|
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
|
var progressRate *ProgressRateV1
|
||||||
if v := runtime.Str("progress-percent"); v != "" {
|
if v := runtime.Str("progress-percent"); v != "" {
|
||||||
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
{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-percent", Desc: "progress percentage"},
|
||||||
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
|
{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: "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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
progressID := runtime.Str("progress-id")
|
progressID := runtime.Str("progress-id")
|
||||||
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
|||||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var cb ContentBlock
|
|
||||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
style := runtime.Str("style")
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
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 != "" {
|
if v := runtime.Str("progress-percent"); v != "" {
|
||||||
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := record.ToResp()
|
style := runtime.Str("style")
|
||||||
result := map[string]interface{}{
|
var result map[string]interface{}
|
||||||
"progress": resp,
|
if style == "simple" {
|
||||||
}
|
resp := record.ToSimple()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
|
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
|
|||||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing --progress-id")
|
t.Fatal("expected error for missing --progress-id")
|
||||||
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "abc",
|
"--progress-id", "abc",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid --progress-id")
|
t.Fatal("expected error for invalid --progress-id")
|
||||||
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", "not-json",
|
"--content", "not-json",
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid --content JSON")
|
t.Fatal("expected error for invalid --content JSON")
|
||||||
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--user-id-type", "invalid",
|
"--user-id-type", "invalid",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--progress-percent", "-999999999999",
|
"--progress-percent", "-999999999999",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--progress-status", "invalid_status",
|
"--progress-status", "invalid_status",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "456",
|
"--progress-id", "456",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "456",
|
"--progress-id", "456",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--progress-percent", "50",
|
"--progress-percent", "50",
|
||||||
"--progress-status", "overdue",
|
"--progress-status", "overdue",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "789",
|
"--progress-id", "789",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "999",
|
"--progress-id", "999",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for API failure")
|
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,
|
OKRReorder,
|
||||||
OKRWeight,
|
OKRWeight,
|
||||||
OKRIndicatorUpdate,
|
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) {
|
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||||
program := &cobra.Command{Use: "root"}
|
program := &cobra.Command{Use: "root"}
|
||||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
|||||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||||
t.Errorf("subtype = %q, want %q", 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)
|
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
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)。
|
||||||
|
|
||||||
## 选哪个命令
|
## 选哪个命令
|
||||||
|
|
||||||
| 想做什么 | 命令 |
|
| 想做什么 | 命令 | 按需读取 reference |
|
||||||
|---|---|
|
|---|---|---------------------------------------------------------------------------------|
|
||||||
| 搜可发起定义 | `approvals search` |
|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||||
| 发起原生审批实例 | `instances create` |
|
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||||
| 看表单/进度/当前节点 | `instances get` |
|
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||||
| 催办 | `tasks remind` |
|
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
| 加签审批 | `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`
|
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
- 处理审批:`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
|
```bash
|
||||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
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
|
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||||
```
|
```
|
||||||
|
|
||||||
## 发起原生审批
|
## 不在本 skill 范围
|
||||||
|
|
||||||
发起审批属于高风险写操作,按下表处理:
|
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||||
|
|
||||||
| 规则 | 处理 |
|
|
||||||
|---|---|
|
|
||||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`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` |
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||||
|
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
- `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` |
|
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
| `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"
|
cliHelp: "lark-cli approval --help"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
**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)。
|
||||||
|
|
||||||
## 选哪个命令
|
## 选哪个命令
|
||||||
|
|
||||||
| 想做什么 | 命令 |
|
| 想做什么 | 命令 | 按需读取 reference |
|
||||||
|---|---|
|
|---|---|---------------------------------------------------------------------------------|
|
||||||
| 搜可发起定义 | `approvals search` |
|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
|
||||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
|
||||||
| 发起原生审批实例 | `instances create` |
|
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
|
||||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
|
||||||
| 看表单/进度/当前节点 | `instances get` |
|
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
|
||||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
|
||||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
|
||||||
| 催办 | `tasks remind` |
|
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
|
||||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
| 加签审批 | `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`
|
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
|
||||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
- 处理审批:`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
|
```bash
|
||||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
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
|
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 范围
|
## 不在本 skill 范围
|
||||||
|
|
||||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `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` 直接拼请求。
|
- **原生审批提单如果用户未明确给出 `approval_code`,必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。
|
- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。
|
||||||
|
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`。
|
||||||
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
|
- **所有人员类参数默认使用 `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`。
|
- **先读控件参数 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` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.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` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
|
- **节点参数只从 `node_list` 和本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。
|
||||||
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_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) 的情况下直接提单。**
|
||||||
- **严禁在未先阅读 [`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`。
|
||||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。
|
|
||||||
- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
|
- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
|
||||||
- **严禁对三方定义调用 `instances.create`。**
|
- **严禁对三方定义调用 `instances create`。**
|
||||||
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
|
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
|
||||||
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
|
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
|
||||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||||
@@ -33,10 +33,9 @@
|
|||||||
|
|
||||||
### 1. 搜索可发起审批定义
|
### 1. 搜索可发起审批定义
|
||||||
|
|
||||||
先用 `schema` 看参数,再搜索定义:
|
先搜索定义:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lark-cli schema approval.approvals.search
|
|
||||||
lark-cli approval approvals search --data '{"keyword":"请假"}'
|
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` 的原生定义才继续下一步。
|
- 只有 `is_external=false` 的原生定义才继续下一步。
|
||||||
|
|
||||||
### 2. 获取审批定义详情
|
### 2. 获取审批定义详情
|
||||||
@@ -52,7 +51,6 @@ lark-cli approval approvals search --data '{"keyword":"请假"}'
|
|||||||
拿到 `approval_code` 后,读取定义详情:
|
拿到 `approval_code` 后,读取定义详情:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lark-cli schema approval.approvals.get
|
|
||||||
lark-cli approval approvals get \
|
lark-cli approval approvals get \
|
||||||
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
|
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
|
||||||
```
|
```
|
||||||
@@ -63,12 +61,30 @@ lark-cli approval approvals get \
|
|||||||
- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
|
- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
|
||||||
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
|
- `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` 后再写入对应控件。
|
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
|
||||||
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
|
- 单选/多选控件提交的是选项 `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) 单独组装,不要套用文本控件的写法。
|
- `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` 是字符串
|
- `input` / `textarea`: `value` 是字符串
|
||||||
- `date`: `value` 是 RFC3339 时间字符串
|
- `date`: `value` 是 RFC3339 时间字符串
|
||||||
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
|
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
|
||||||
- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id`
|
- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id`
|
||||||
- `checkbox` / `checkboxV2`: `value` 是选项值数组
|
- `checkbox` / `checkboxV2`: `value` 是选项值数组
|
||||||
- `number`: `value` 是数字
|
- `number`: `value` 是数字
|
||||||
- `amount`: `value` 是数字,还要带 `currency`
|
- `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`
|
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
|
||||||
- 不要把控件组整体当成普通字符串或扁平对象提交
|
- 不要把控件组整体当成普通字符串或扁平对象提交
|
||||||
|
|
||||||
### 4. 组装节点参数
|
### 5. 组装节点参数
|
||||||
|
|
||||||
从 `node_list` 推导节点参数:
|
从 `node_list` 推导节点参数:
|
||||||
|
|
||||||
@@ -139,13 +155,13 @@ lark-cli approval approvals get \
|
|||||||
- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
|
- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
|
||||||
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
|
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
|
||||||
|
|
||||||
### 5. 创建审批实例
|
### 6. 创建审批实例
|
||||||
|
|
||||||
先看 `schema`,确认最终结构后再执行:
|
创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"]
|
||||||
|
|
||||||
|
确认最终表单值和节点参数后再执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lark-cli schema approval.instances.create
|
|
||||||
|
|
||||||
lark-cli approval instances create \
|
lark-cli approval instances create \
|
||||||
--data '{
|
--data '{
|
||||||
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
|
"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
|
--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` 结构与支持范围。
|
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):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
|
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
|
||||||
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
|
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
|
||||||
@@ -184,8 +202,8 @@ lark-cli approval instances create \
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
|
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
|
||||||
| 已经拿到 `approval_code` | 直接 `approvals.get` |
|
| 已经拿到 `approval_code` | 直接 `approvals.get` |
|
||||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
|
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` |
|
||||||
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
|
| `is_external=true` | 返回 `create_link`,不要调 `instances create` |
|
||||||
|
|
||||||
## 返回结果
|
## 返回结果
|
||||||
|
|
||||||
@@ -194,3 +212,13 @@ lark-cli approval instances create \
|
|||||||
- `approval_name`
|
- `approval_name`
|
||||||
- `instance_code`
|
- `instance_code`
|
||||||
- `instance_link`
|
- `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`
|
2. `approval approvals get` 返回的 `form` / `node_list`
|
||||||
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
|
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
|
||||||
4. 本文
|
4. 本文
|
||||||
|
|
||||||
## 总原则
|
## 总原则
|
||||||
|
|
||||||
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
|
- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。
|
||||||
- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
|
- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
|
||||||
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
|
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
|
||||||
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
|
- [`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` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。
|
||||||
@@ -12,7 +12,7 @@ metadata:
|
|||||||
|
|
||||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||||
|
|
||||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
**CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
|
||||||
|
|
||||||
## 身份
|
## 身份
|
||||||
|
|
||||||
@@ -30,28 +30,84 @@ lark-cli calendar +agenda --as user
|
|||||||
|
|
||||||
| Shortcut | 说明 |
|
| Shortcut | 说明 |
|
||||||
|----------|------|
|
|----------|------|
|
||||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
| `+agenda` | 查看日程安排(默认今天) |
|
||||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
|
|
||||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
|
||||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
| `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion`) |
|
||||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||||
|
|
||||||
|
### `+get` — 单日程详情
|
||||||
|
|
||||||
|
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# calendar_id不传,默认primary
|
||||||
|
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||||
|
|
||||||
|
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# query 按关键词 可选
|
||||||
|
# start/end 按时间范围(ISO 8601 或 YYYY-MM-DD)可选
|
||||||
|
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
|
||||||
|
# page-token 分页游标,用于继续翻页 可选
|
||||||
|
# page-size 每页数量,默认 30 可选
|
||||||
|
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### `+agenda` — 查看近期日程安排
|
||||||
|
|
||||||
|
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||||
|
# calendar-id 日历 ID(默认primary)可选
|
||||||
|
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
|
||||||
|
- 时间范围超过 40 天会自动拆分查询并合并结果。
|
||||||
|
|
||||||
|
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
|
||||||
|
|
||||||
|
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# start/end 时间范围(ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
|
||||||
|
# user-id 目标用户 open_id(ou_ 前缀)可选;默认当前登录用户,bot 身份必须显式指定
|
||||||
|
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
用法提示:
|
||||||
|
- **仅判断是否有空** → `+freebusy`;**需要日程详情** → `+agenda`。
|
||||||
|
- 检查多人可用性:分别调用并对比,找共同空闲。
|
||||||
|
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
|
||||||
|
|
||||||
## 前置条件路由
|
## 前置条件路由
|
||||||
|
|
||||||
| 场景 | 前置要求 |
|
| 场景 | 前置要求 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
| 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
| 仅编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
|
||||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
| 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||||
|
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||||
|
|
||||||
|
## 写操作反馈
|
||||||
|
|
||||||
|
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||||
|
- **重复性日程例外(Exception)**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
|
||||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||||
@@ -70,7 +126,9 @@ lark-cli calendar +agenda --as user
|
|||||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
| 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||||
|
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
|
||||||
|
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||||
|
|
||||||
## 任务类型分流
|
## 任务类型分流
|
||||||
|
|
||||||
@@ -87,7 +145,7 @@ lark-cli calendar +agenda --as user
|
|||||||
|
|
||||||
## 会议室规则
|
## 会议室规则
|
||||||
|
|
||||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)。
|
||||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||||
@@ -95,42 +153,45 @@ lark-cli calendar +agenda --as user
|
|||||||
## API Resources
|
## API Resources
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 通用调用格式
|
||||||
lark-cli calendar <resource> <method> [flags]
|
lark-cli calendar <resource> <method> [flags]
|
||||||
|
|
||||||
|
# 查询用户主日历
|
||||||
|
lark-cli calendar calendars primary
|
||||||
|
|
||||||
|
# 获取日程分享链接
|
||||||
|
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
|
||||||
|
|
||||||
|
# 删除日程
|
||||||
|
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### calendars
|
> `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
|
||||||
|
|
||||||
- `create` — 创建共享日历
|
### 查询资源的方法列表以及方法的使用方式
|
||||||
- `delete` — 删除共享日历
|
|
||||||
- `get` — 查询日历信息
|
|
||||||
- `list` — 查询日历列表
|
|
||||||
- `patch` — 更新日历信息
|
|
||||||
- `primary` — 查询用户主日历
|
|
||||||
- `search` — 搜索日历
|
|
||||||
|
|
||||||
### event.attendees
|
- 列出某资源下的方法:`lark-cli calendar <resource> -h`
|
||||||
|
- 查看方法的cli flag:`lark-cli calendar <resource> <method> -h`
|
||||||
|
- 查看方法API参数:`lark-cli schema calendar.<resource>.<method>`
|
||||||
|
|
||||||
- `batch_delete` — 删除日程参与人
|
`<resource>` 为 `calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`。
|
||||||
- `create` — 添加日程参与人
|
|
||||||
- `list` — 获取日程参与人列表
|
|
||||||
|
|
||||||
### events
|
## 常用其他域命令
|
||||||
|
|
||||||
- `create` — 创建日程
|
```bash
|
||||||
- `delete` — 删除日程
|
# 搜索用户,更多参数详见 lark-contact
|
||||||
- `get` — 获取日程
|
lark-cli contact +search-user --query <query> --as user
|
||||||
- `instance_view` — 查询日程视图
|
|
||||||
- `patch` — 更新日程
|
|
||||||
- `share_info` — 获取日程分享链接
|
|
||||||
|
|
||||||
### freebusys
|
# 搜索群聊,更多参数详见 lark-im
|
||||||
|
lark-cli im +chat-search --query <query> --as user
|
||||||
- `list` — 查询主日历日程忙闲信息
|
```
|
||||||
|
|
||||||
## 不在本 skill 范围
|
## 不在本 skill 范围
|
||||||
|
|
||||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||||
|
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
|
||||||
|
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
|
||||||
- 会议室物理设施管理 → 管理员后台
|
- 会议室物理设施管理 → 管理员后台
|
||||||
|
|
||||||
**注意(强制性):**
|
**注意(强制性):**
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
|
|
||||||
# calendar +agenda
|
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
查看近期日程安排。只读操作,不修改任何日程。
|
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.event:read"]
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看今天日程(默认)
|
|
||||||
lark-cli calendar +agenda
|
|
||||||
|
|
||||||
# 自定义时间范围(ISO 8601)
|
|
||||||
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
|
|
||||||
|
|
||||||
# 自定义时间范围(仅日期)
|
|
||||||
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
|
|
||||||
|
|
||||||
# 人类可读格式输出
|
|
||||||
lark-cli calendar +agenda --format pretty
|
|
||||||
|
|
||||||
# 指定日历
|
|
||||||
lark-cli calendar +agenda --calendar-id cal_xxx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参数
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `--start <time>` | 否 | 开始时间(ISO 8601 或仅日期,默认当天) |
|
|
||||||
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
|
||||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
|
||||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
|
||||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
|
||||||
|
|
||||||
## 时间格式
|
|
||||||
|
|
||||||
`--start` 和 `--end` 支持以下格式:
|
|
||||||
|
|
||||||
| 格式 | 示例 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
|
|
||||||
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
|
|
||||||
| 仅日期 | `2026-03-10` | start 取 00:00:00,end 取 23:59:59 |
|
|
||||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
|
||||||
|
|
||||||
## 输出格式
|
|
||||||
|
|
||||||
**将结果整理为易读的日程表:**
|
|
||||||
|
|
||||||
```
|
|
||||||
## 2026-03-10 周一
|
|
||||||
|
|
||||||
09:00 - 09:30 站会
|
|
||||||
10:00 - 11:00 产品评审
|
|
||||||
14:00 - 15:00 与 Alice 1:1
|
|
||||||
|
|
||||||
## 2026-03-11 周二
|
|
||||||
|
|
||||||
(无日程)
|
|
||||||
```
|
|
||||||
|
|
||||||
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
|
|
||||||
|
|
||||||
## 提示
|
|
||||||
|
|
||||||
- 已取消的日程会自动过滤,无需额外处理。
|
|
||||||
- 如无日程,告知用户"日程清空"。
|
|
||||||
- 大于 40 天的时间范围会自动拆分查询并合并结果。
|
|
||||||
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
|
|
||||||
|
|
||||||
## 参考
|
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
|
|
||||||
# calendar +create
|
# calendar +create
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
创建日程并按需邀请参会人。
|
创建日程并按需邀请参会人。
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
|
|
||||||
|
|
||||||
## 推荐命令
|
## 推荐命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,15 +35,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
|||||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||||
|
|
||||||
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
|
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||||
|
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||||
|
|
||||||
## 高级用法(完整 API 命令)
|
## 高级用法(完整 API 命令)
|
||||||
|
|
||||||
@@ -55,37 +53,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
|||||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 第一步:创建日程(含高级参数)
|
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||||
## 查看完整参数定义
|
|
||||||
lark-cli schema calendar.events.create
|
|
||||||
## 创建日程
|
|
||||||
lark-cli calendar events create \
|
|
||||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
|
||||||
--data '{
|
|
||||||
"summary": "技术分享:CLI 架构设计",
|
|
||||||
"start_time": { "timestamp": "1741586400" },
|
|
||||||
"end_time": { "timestamp": "1741593600" }
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
|
||||||
## 查看完整参数定义
|
|
||||||
lark-cli schema calendar.event.attendees.create
|
|
||||||
## 添加参会人
|
|
||||||
lark-cli calendar event.attendees create \
|
lark-cli calendar event.attendees create \
|
||||||
|
--as user \
|
||||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||||
|
|
||||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
完整 API 命令的关键差异:
|
||||||
## 查看完整参数定义
|
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||||
lark-cli schema calendar.events.delete
|
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||||
## 删除空日程
|
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||||
lark-cli calendar events delete \
|
|
||||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
|
||||||
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
|
|
||||||
|
|
||||||
## 参会人类型
|
## 参会人类型
|
||||||
|
|
||||||
@@ -101,6 +78,5 @@ lark-cli calendar events delete \
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
|
|
||||||
# calendar +freebusy
|
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
|
||||||
|
|
||||||
查询用户主日历的忙闲信息,返回指定时间范围内的忙碌时段列表和rsvp的状态。
|
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.free_busy:read"]
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查询当前用户今天的忙闲(默认)
|
|
||||||
lark-cli calendar +freebusy
|
|
||||||
|
|
||||||
# 自定义时间范围(仅日期)
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
|
|
||||||
|
|
||||||
# 自定义时间范围(完整 ISO 8601)
|
|
||||||
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
|
|
||||||
|
|
||||||
# 查询指定用户的忙闲信息
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
|
|
||||||
|
|
||||||
# 人类可读格式输出
|
|
||||||
lark-cli calendar +freebusy --format pretty
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参数
|
|
||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `--start <time>` | 否 | 查询开始时间(ISO 8601 或仅日期,默认当天) |
|
|
||||||
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
|
||||||
| `--user-id <open_id>` | 否 | 目标查询用户 ID(`ou_` 前缀)。省略时默认查询当前登录用户,bot 身份调用时必须明确指定 |
|
|
||||||
| `--format` | 否 | 输出格式:json(默认) \| pretty |
|
|
||||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
|
||||||
|
|
||||||
## 时间格式
|
|
||||||
|
|
||||||
`--start` 和 `--end` 支持以下格式:
|
|
||||||
|
|
||||||
| 格式 | 示例 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
|
|
||||||
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
|
|
||||||
| 仅日期 | `2026-03-11` | start 取 00:00:00,end 取 23:59:59 |
|
|
||||||
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
|
|
||||||
|
|
||||||
## 输出示例
|
|
||||||
|
|
||||||
### 表格格式
|
|
||||||
|
|
||||||
```
|
|
||||||
start end rsvp_status
|
|
||||||
---------------- ---------------- -----------
|
|
||||||
2026-03-11 10:00 2026-03-11 10:30 接受
|
|
||||||
2026-03-11 14:00 2026-03-11 15:00 待定
|
|
||||||
|
|
||||||
共 2 个忙碌时段
|
|
||||||
```
|
|
||||||
|
|
||||||
### JSON 格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"start_time": "2026-03-11T10:00:00+08:00",
|
|
||||||
"end_time": "2026-03-11T10:30:00+08:00",
|
|
||||||
"rsvp_status": "accept"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start_time": "2026-03-11T14:00:00+08:00",
|
|
||||||
"end_time": "2026-03-11T15:00:00+08:00",
|
|
||||||
"rsvp_status": "tentative"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 典型场景
|
|
||||||
|
|
||||||
### 1. 查找日程会议空闲时段
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查询今天的忙碌时段
|
|
||||||
lark-cli calendar +freebusy
|
|
||||||
|
|
||||||
# 查询工作时间段
|
|
||||||
lark-cli calendar +freebusy \
|
|
||||||
--start "2026-03-11T08:00:00+08:00" \
|
|
||||||
--end "2026-03-11T18:00:00+08:00"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 检查团队成员可用性
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查询多个成员,对比找出共同空闲时间
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
|
|
||||||
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
|
|
||||||
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
|
|
||||||
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
|
|
||||||
|
|
||||||
## 与其他命令对比
|
|
||||||
|
|
||||||
| 命令 | 用途 | 输出内容 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
|
|
||||||
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
|
|
||||||
|
|
||||||
**选择建议**:
|
|
||||||
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
|
|
||||||
- **需要查看日程详情** → 使用 `+agenda`
|
|
||||||
|
|
||||||
## 参考
|
|
||||||
|
|
||||||
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
|
|
||||||
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
|
|
||||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
|
|
||||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
|
||||||
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 重复性日程操作规范
|
||||||
|
|
||||||
|
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
|
||||||
|
|
||||||
|
## 关键概念
|
||||||
|
|
||||||
|
- **event_id 结构**:`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime` 为 `0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`。
|
||||||
|
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性(标题、时间、rrule、描述等)都挂在本体上。
|
||||||
|
- **例外(Exception)**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}`(`originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
|
||||||
|
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
|
||||||
|
|
||||||
|
## 前置步骤(所有范围通用)
|
||||||
|
|
||||||
|
1. 通过 `+agenda` 或 `+search-event` 定位重复性日程,获取原重复性日程的 `event_id`。
|
||||||
|
2. 通过 `events instance_view` 或 `+agenda` 列出实例,识别哪些是例外(`event_id` 中 `originalTime > 0` 的即为例外)。
|
||||||
|
3. 确认用户的操作范围。
|
||||||
|
|
||||||
|
## 编辑全部(更新时间)
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
|
||||||
|
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
|
||||||
|
|
||||||
|
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
|
||||||
|
|
||||||
|
## 编辑全部(更新非时间字段)
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
|
||||||
|
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||||
|
|
||||||
|
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
|
||||||
|
|
||||||
|
## 删除全部
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
|
||||||
|
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
|
||||||
|
|
||||||
|
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
|
||||||
|
|
||||||
|
## 编辑此次及后续
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程(UNTIL 设为指定时间前一次实例的日期) |
|
||||||
|
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
|
||||||
|
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
|
||||||
|
|
||||||
|
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」,UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
|
||||||
|
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
|
||||||
|
|
||||||
|
## 删除此次及后续
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程(UNTIL 设为指定时间前一次实例的日期) |
|
||||||
|
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
|
||||||
|
|
||||||
|
> 与「编辑此次及后续」的区别:不需要步骤 3(创建新的重复性日程),因为目标是删除后续而非替换。
|
||||||
|
|
||||||
|
## 仅此次
|
||||||
|
|
||||||
|
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`。
|
||||||
|
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`。
|
||||||
|
|
||||||
|
## 用户意图映射
|
||||||
|
|
||||||
|
| 用户表达 | 操作范围 |
|
||||||
|
|----------|----------|
|
||||||
|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
|
||||||
|
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
|
||||||
|
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
|
||||||
|
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
|
||||||
|
| 「就改这一次」「只删这一次」 | 仅此次 |
|
||||||
|
| 未明确范围 | **必须询问用户** |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- [lark-calendar](../SKILL.md) — 日历全部命令
|
||||||
|
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
|
||||||
|
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
|
||||||
|
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
# calendar +room-find
|
# calendar +room-find
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
|
||||||
|
|
||||||
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
|
||||||
|
|
||||||
需要的 scopes: ["calendar:calendar.free_busy:read"]
|
|
||||||
|
|
||||||
## 适用场景
|
## 适用场景
|
||||||
|
|
||||||
- 已知一个或多个待选时间块,需要查找可用会议室
|
- 已知一个或多个待选时间块,需要查找可用会议室
|
||||||
@@ -50,7 +47,7 @@ lark-cli calendar +room-find \
|
|||||||
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
|
||||||
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
|
||||||
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 |
|
||||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 |
|
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||||
@@ -67,7 +64,7 @@ lark-cli calendar +room-find \
|
|||||||
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
- 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。
|
||||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
||||||
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。
|
||||||
- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
- 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
|
||||||
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
|
||||||
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
- "查下木星和火星是否有空" → `--room-name "木星,火星"`
|
||||||
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
- "看看 01、02、03 会议室" → `--room-name "01,02,03"`
|
||||||
@@ -90,9 +87,8 @@ lark-cli calendar +room-find \
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **AI 行为指导:**
|
> **AI 行为指导:**
|
||||||
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。
|
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向。
|
||||||
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
|
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
|
||||||
> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。
|
|
||||||
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
|
||||||
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
|
||||||
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
|
||||||
@@ -102,7 +98,7 @@ lark-cli calendar +room-find \
|
|||||||
| 字段名 | 说明 |
|
| 字段名 | 说明 |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
|
||||||
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 |
|
| `room_name` | 会议室名称,展示给用户时必须使用原值。 |
|
||||||
| `capacity` | 会议室最大容纳人数。 |
|
| `capacity` | 会议室最大容纳人数。 |
|
||||||
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
|
||||||
|
|
||||||
@@ -110,4 +106,4 @@ lark-cli calendar +room-find \
|
|||||||
|
|
||||||
- [lark-calendar-create](lark-calendar-create.md)
|
- [lark-calendar-create](lark-calendar-create.md)
|
||||||
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
- [lark-calendar-suggestion](lark-calendar-suggestion.md)
|
||||||
- [lark-calendar](../SKILL.md) — 日历完整 API
|
- [lark-calendar](../SKILL.md) — skill 入口与路由
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
# calendar +rsvp
|
# calendar +rsvp
|
||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
|
||||||
|
|
||||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||||
|
|
||||||
需要的scopes: ["calendar:calendar.event:reply"]
|
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,5 +35,4 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
- [lark-calendar](../SKILL.md) -- skill 入口与路由
|
||||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 明确时间分支:room-find + freebusy + 冲突处理
|
||||||
|
|
||||||
|
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||||
|
- 任务类型判定(新建 / 编辑)
|
||||||
|
- 编辑流:目标 event_id 已定位
|
||||||
|
- 新建流:默认值已补全
|
||||||
|
- 时间已判定为**明确**
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
### 1. 查询会议室(如需)
|
||||||
|
|
||||||
|
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli calendar +room-find \
|
||||||
|
--slot "<start>~<end>" \
|
||||||
|
--attendee-ids "<ids>" \
|
||||||
|
--city "<city>" \
|
||||||
|
--building "<building>" \
|
||||||
|
--floor "<F2>" \
|
||||||
|
--room-name "<room_name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
时间块确定规则:
|
||||||
|
- **编辑流且不改时间,只新增会议室**:`--slot` 必须来自已定位日程的当前 `start/end`
|
||||||
|
- **编辑流且既改时间又加会议室**:`--slot` 必须来自候选新时间,而不是旧时间
|
||||||
|
|
||||||
|
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||||
|
|
||||||
|
### 2. 查询忙闲
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||||
|
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||||
|
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||||
|
|
||||||
|
### 3. 冲突处理
|
||||||
|
|
||||||
|
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
|
||||||
|
- **有冲突**:必须先说明冲突情况,询问用户:
|
||||||
|
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
|
||||||
|
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||||
|
|
||||||
|
## 落地
|
||||||
|
|
||||||
|
根据任务类型:
|
||||||
|
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||||
|
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||||
|
|
||||||
|
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 模糊时间 / 无时间信息分支:suggestion + 批量查询
|
||||||
|
|
||||||
|
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
|
||||||
|
- 任务类型判定(新建 / 编辑)
|
||||||
|
- 编辑流:目标 event_id 已定位
|
||||||
|
- 新建流:默认值已补全
|
||||||
|
- 时间已判定为**模糊**或**无时间信息**
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
### 1. 调用 suggestion
|
||||||
|
|
||||||
|
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lark-cli calendar +suggestion \
|
||||||
|
--start "<range_start>" \
|
||||||
|
--end "<range_end>" \
|
||||||
|
--attendee-ids "<ids>" \
|
||||||
|
--duration-minutes <n> \
|
||||||
|
--event-rrule "<rrule>"
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
|
||||||
|
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
|
||||||
|
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
|
||||||
|
|
||||||
|
### 2. 分支处理
|
||||||
|
|
||||||
|
#### 不需要会议室
|
||||||
|
|
||||||
|
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
|
||||||
|
|
||||||
|
#### 需要会议室
|
||||||
|
|
||||||
|
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
|
||||||
|
|
||||||
|
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
|
||||||
|
|
||||||
|
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
|
||||||
|
|
||||||
|
### 3. 用户确认后
|
||||||
|
|
||||||
|
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
|
||||||
|
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
|
||||||
|
|
||||||
|
## 模糊语义消解与长期记忆
|
||||||
|
|
||||||
|
针对存在歧义的时间场景,严禁主观臆断。典型例子:
|
||||||
|
- "上班后" / "下班前"
|
||||||
|
- 未明确上下午的 12 小时制时间
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
- 主动澄清真实意图,不自行猜测
|
||||||
|
- 用户澄清后,将个性化定义沉淀为长期偏好
|
||||||
|
|
||||||
|
## 用户展示格式
|
||||||
|
|
||||||
|
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## 2026-03-27 周五
|
||||||
|
|
||||||
|
[选项 1] 14:00 - 15:00(参会人均空闲)
|
||||||
|
可用会议室:
|
||||||
|
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
||||||
|
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
||||||
|
|
||||||
|
[选项 2] 16:00 - 17:00(参会人均空闲)
|
||||||
|
可用会议室:
|
||||||
|
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
||||||
|
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
||||||
|
|
||||||
|
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 落地
|
||||||
|
|
||||||
|
根据任务类型:
|
||||||
|
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||||
|
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||||
|
|
||||||
|
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。
|
||||||
@@ -1,206 +1,95 @@
|
|||||||
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
# 预约/改约日程或会议、查询/搜索可用会议室的工作流
|
||||||
|
|
||||||
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则)
|
## 执行摘要
|
||||||
|
|
||||||
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。
|
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
|
||||||
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。
|
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
|
||||||
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。
|
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。
|
||||||
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。
|
- **新建流先补默认值,编辑流先继承已定位日程信息。**
|
||||||
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。
|
- **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
|
||||||
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。
|
- **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
|
||||||
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。
|
- **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程。
|
||||||
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。
|
- **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤。
|
||||||
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
|
|
||||||
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
|
|
||||||
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
|
|
||||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
|
|
||||||
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
|
|
||||||
|
|
||||||
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
|
|
||||||
|
|
||||||
## 严禁行为
|
## 严禁行为
|
||||||
|
|
||||||
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。
|
- **严禁在未读取对应子命令文档前直接调用命令。**
|
||||||
- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。**
|
- **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
|
||||||
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。
|
- **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
|
||||||
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。
|
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`。
|
||||||
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。
|
- **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
|
||||||
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。
|
|
||||||
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
|
|
||||||
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
|
|
||||||
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
|
|
||||||
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
|
|
||||||
|
|
||||||
## 适用场景
|
## 适用场景
|
||||||
|
|
||||||
- “帮我约个会”
|
- "帮我约个会" / "下周找时间和 XX 开会"
|
||||||
- “下周找时间和 XX 开会”
|
- "帮我订/找/搜索一个可用会议室"
|
||||||
- “帮我订个会议室”
|
- "明天下午3点约个日程"
|
||||||
- “帮我找/搜索一个可用的会议室”
|
- "把明天上午的日程加上 小明"
|
||||||
- “帮我推荐一个我以前常用的会议室”
|
- "给下周一的周会换个会议室"
|
||||||
- “查询明天下午可用的会议室”
|
- "把这个日程改到明天下午,并加上学清 F201"
|
||||||
- “明天下午3点约个日程/日历”
|
|
||||||
- “把明天上午的日程‘产品发布会’加上 小明
|
|
||||||
- “给下周一的周会换个会议室”
|
|
||||||
- “把这个日程改到明天下午,并加上学清 F201”
|
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。**
|
||||||
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。
|
- **预定或查找会议室,均需先确定时间块。**
|
||||||
|
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
|
||||||
## CRITICAL 约束
|
|
||||||
|
|
||||||
- **在调用任何具体的 CLI 子命令(如 `+room-find`、`+suggestion`、`+freebusy`、`+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
|
|
||||||
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
|
|
||||||
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
|
|
||||||
|
|
||||||
## 任务类型判定
|
## 任务类型判定
|
||||||
|
|
||||||
| 类型 | 典型语言信号 | 第一动作 |
|
| 类型 | 典型语言信号 | 第一动作 |
|
||||||
|------|--------------|----------|
|
|------|--------------|----------|
|
||||||
| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 |
|
| 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
|
||||||
| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 |
|
| 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `event_id` |
|
||||||
|
|
||||||
进一步规则:
|
规则:
|
||||||
|
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`)和**修改动词**(添加、移除、改到、换),默认判定为编辑。
|
||||||
|
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`。
|
||||||
|
|
||||||
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。
|
## 编辑流:先定位目标日程
|
||||||
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
|
|
||||||
|
|
||||||
## 工作流
|
|
||||||
|
|
||||||
### 1. 编辑已有日程:先定位目标日程
|
|
||||||
|
|
||||||
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
|
|
||||||
|
|
||||||
定位规则:
|
定位规则:
|
||||||
|
- 优先利用用户给出的标题、日期、时间范围等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围
|
||||||
|
- 命中多个候选日程时,必须向用户展示候选项并要求确认
|
||||||
|
- 重复性日程必须继续定位到该次实例的 `event_id`
|
||||||
|
|
||||||
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。
|
编辑流分支路由:
|
||||||
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
|
|
||||||
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。
|
|
||||||
|
|
||||||
编辑流分支规则:
|
| 编辑子场景 | 下一步 |
|
||||||
|
|-----------|--------|
|
||||||
|
| 仅增删普通参会人/群组,不改时间,不涉及会议室 | 直接 `+update`(详见 [lark-calendar-update.md](./lark-calendar-update.md)) |
|
||||||
|
| 新增会议室,不改时间 | 基于已定位日程 start/end → [明确时间分支](./lark-calendar-schedule-clear-time.md) |
|
||||||
|
| 只改时间,不涉及会议室 | 判断时间明确性 → 对应分支 |
|
||||||
|
| 既改时间,又新增/更换会议室 | 先确定最终时间 → 再查会议室 → 落地 |
|
||||||
|
|
||||||
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`。
|
## 新建日程:智能推断默认值
|
||||||
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
|
|
||||||
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
|
|
||||||
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
|
|
||||||
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`。
|
|
||||||
|
|
||||||
### 2. 新建日程:智能推断默认值
|
- **标题**:根据上下文自动生成;如无法推断,默认"会议"
|
||||||
以下信息智能推断,减少频繁询问用户:
|
- **参会人**:如未指定,默认仅用户自己
|
||||||
|
- **时长**:基于上下文推断;默认 30 分钟
|
||||||
|
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
|
||||||
|
|
||||||
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议”
|
搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
|
||||||
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
|
|
||||||
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
|
|
||||||
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
|
|
||||||
|
|
||||||
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。
|
## 判断时间是否明确
|
||||||
|
|
||||||
### 3. 判断时间是否明确
|
|
||||||
|
|
||||||
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
|
|
||||||
|
|
||||||
时间基准规则:
|
时间基准规则:
|
||||||
|
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
|
||||||
|
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
|
||||||
|
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
|
||||||
|
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
|
||||||
|
|
||||||
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。
|
## 分支路由
|
||||||
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
|
|
||||||
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。
|
|
||||||
|
|
||||||
分两类处理:
|
| 判定结果 | 下一步读取 |
|
||||||
|
|----------|-----------|
|
||||||
|
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
|
||||||
|
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
|
||||||
|
|
||||||
- **明确时间**:如“明天下午3点”
|
## 落地日程变更
|
||||||
- **模糊时间**:如“明天下午”“下周找个时间”
|
|
||||||
|
|
||||||
### 4. 明确时间
|
|
||||||
|
|
||||||
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
|
|
||||||
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 如果需要会议室,提前查询会议室
|
|
||||||
lark-cli calendar +room-find \
|
|
||||||
--slot "<start>~<end>" \
|
|
||||||
--attendee-ids "<ids>" \
|
|
||||||
--city "<city>" \
|
|
||||||
--building "<building>" \
|
|
||||||
--floor "<F2>" \
|
|
||||||
--room-name "<room_name>"
|
|
||||||
|
|
||||||
# 2. 查询当前用户及其他参会人忙闲
|
|
||||||
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>")
|
|
||||||
lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- **参会人过多或包含群组时的处理**:
|
|
||||||
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
|
|
||||||
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
|
|
||||||
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。
|
|
||||||
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
|
|
||||||
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
|
||||||
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
|
|
||||||
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
|
|
||||||
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
|
|
||||||
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。
|
|
||||||
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。
|
|
||||||
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。
|
|
||||||
- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。
|
|
||||||
|
|
||||||
### 5. 模糊时间或无时间信息
|
|
||||||
|
|
||||||
先调用:
|
|
||||||
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lark-cli calendar +suggestion \
|
|
||||||
--start "<range_start>" \
|
|
||||||
--end "<range_end>" \
|
|
||||||
--attendee-ids "<ids>" \
|
|
||||||
--duration-minutes <n> \
|
|
||||||
--event-rrule "<rrule>"
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
|
|
||||||
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
|
|
||||||
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
|
|
||||||
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
|
|
||||||
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
|
|
||||||
|
|
||||||
### 6. 模糊语义消解与长期记忆构建
|
|
||||||
|
|
||||||
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
|
|
||||||
|
|
||||||
- “上班后”
|
|
||||||
- “下班前”
|
|
||||||
- 未明确上下午的 12 小时制时间表达
|
|
||||||
|
|
||||||
处理规则:
|
|
||||||
|
|
||||||
- 应主动澄清真实意图,而不是自行猜测
|
|
||||||
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
|
|
||||||
|
|
||||||
### 7. 重复性日程
|
|
||||||
|
|
||||||
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。
|
|
||||||
|
|
||||||
必须检查返回中的:
|
|
||||||
|
|
||||||
- `reserve_until_time`
|
|
||||||
|
|
||||||
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
|
|
||||||
|
|
||||||
- 向用户明确说明该会议室最长可约至何时。
|
|
||||||
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
|
|
||||||
|
|
||||||
### 8. 落地日程变更
|
|
||||||
|
|
||||||
用户确认后调用:
|
用户确认后调用:
|
||||||
如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。
|
- 新建 → [`+create`](./lark-calendar-create.md)
|
||||||
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。
|
- 编辑 → [`+update`](./lark-calendar-update.md)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lark-cli calendar +create \
|
lark-cli calendar +create \
|
||||||
@@ -214,52 +103,20 @@ lark-cli calendar +update \
|
|||||||
--start "<start>" \
|
--start "<start>" \
|
||||||
--end "<end>" \
|
--end "<end>" \
|
||||||
--add-attendee-ids "omm_new_room"
|
--add-attendee-ids "omm_new_room"
|
||||||
|
|
||||||
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
|
|
||||||
lark-cli calendar +update \
|
|
||||||
--event-id "<event_id>" \
|
|
||||||
--remove-attendee-ids "omm_old_room" \
|
|
||||||
--add-attendee-ids "omm_new_room"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
规则:
|
落地规则:
|
||||||
- 新建日程时,可使用 `+create`
|
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
|
||||||
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行;
|
- 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
|
||||||
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。
|
- 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids` 新
|
||||||
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。
|
- 需要会议室时,将选中的 `room_id` 写入参与人列表
|
||||||
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
|
|
||||||
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
|
|
||||||
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
|
|
||||||
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
|
|
||||||
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
|
|
||||||
|
|
||||||
## 用户展示建议
|
|
||||||
|
|
||||||
当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。
|
|
||||||
|
|
||||||
**推荐展示格式参考:**
|
|
||||||
|
|
||||||
```text
|
|
||||||
## 2026-03-27 周五
|
|
||||||
|
|
||||||
[选项 1] 14:00 - 15:00(参会人均空闲)
|
|
||||||
可用会议室:
|
|
||||||
1. 学清嘉创大厦B座-F2-02🎦(7人)
|
|
||||||
2. 学清嘉创大厦B座-F2-05🎦(10人)
|
|
||||||
|
|
||||||
[选项 2] 16:00 - 17:00(参会人均空闲)
|
|
||||||
可用会议室:
|
|
||||||
1. 学清嘉创大厦B座-F3-01🎦(6人)
|
|
||||||
2. 学清嘉创大厦B座-F3-06🎦(8人)
|
|
||||||
|
|
||||||
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
|
- [lark-calendar-schedule-clear-time.md](./lark-calendar-schedule-clear-time.md)
|
||||||
|
- [lark-calendar-schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md)
|
||||||
- [lark-calendar-room-find.md](./lark-calendar-room-find.md)
|
- [lark-calendar-room-find.md](./lark-calendar-room-find.md)
|
||||||
- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md)
|
|
||||||
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
|
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
|
||||||
- [lark-calendar-create.md](./lark-calendar-create.md)
|
- [lark-calendar-create.md](./lark-calendar-create.md)
|
||||||
- [lark-shared](../../lark-shared/SKILL.md)
|
- [lark-calendar-update.md](./lark-calendar-update.md)
|
||||||
- [lark-calendar](../SKILL.md)
|
- [SKILL.md](../SKILL.md)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
# calendar +search-event
|
|
||||||
|
|
||||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 按关键词
|
|
||||||
lark-cli calendar +search-event --query "周会"
|
|
||||||
|
|
||||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
|
||||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
|
||||||
|
|
||||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
|
||||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
|
||||||
|
|
||||||
# 组合
|
|
||||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 输出字段
|
|
||||||
|
|
||||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
|
||||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user