mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09e60eeaf4 | ||
|
|
4f90fd3b77 | ||
|
|
6212513c43 | ||
|
|
e8df0ea63e | ||
|
|
6d0d687be2 | ||
|
|
148a04a7f8 | ||
|
|
ba19bd9f93 | ||
|
|
830fb3bbe5 | ||
|
|
1ad7cfab5b | ||
|
|
5280517d4b | ||
|
|
3ad6f2fac4 | ||
|
|
be79485fe3 | ||
|
|
94bba91224 | ||
|
|
0d50616e77 | ||
|
|
d5784eac28 | ||
|
|
663c24aadf | ||
|
|
6ad25cd452 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -153,14 +153,14 @@ jobs:
|
||||
run: |
|
||||
# Analyze current HEAD (strip line:col for stable diff across line shifts)
|
||||
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
|
||||
|
||||
# Analyze base branch via worktree
|
||||
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
|
||||
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
|
||||
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
|
||||
grep -v '^go: ' | \
|
||||
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
|
||||
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
|
||||
@@ -209,6 +209,7 @@ jobs:
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add email priority support for compose and read (#538)
|
||||
- **mail**: Support scheduled send (#534)
|
||||
- **drive**: Support sheet cell comments in `+add-comment` (#518)
|
||||
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
|
||||
- **base**: Auto grant current user for bot create and copy (#497)
|
||||
- **base**: Add identity priority strategy and error handling (#505)
|
||||
- **auth**: Improve login scope handling and messages (#523)
|
||||
- Add OKR business domain (#522)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
|
||||
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
|
||||
|
||||
## [v1.0.13] - 2026-04-16
|
||||
|
||||
### Features
|
||||
@@ -382,6 +404,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
|
||||
|
||||
@@ -30,7 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
@@ -38,6 +38,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type loginMsg struct {
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
@@ -58,9 +59,10 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常:以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
@@ -93,9 +95,10 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
AuthSuccess: "Authorization completed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
|
||||
@@ -69,6 +69,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// AuthorizedUser should contain two %s placeholders (userName, openId)
|
||||
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
|
||||
if got == msg.AuthorizedUser {
|
||||
t.Errorf("%s AuthorizedUser has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
@@ -190,11 +190,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
if msg.AuthorizedUser != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -376,8 +376,8 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
@@ -392,15 +392,15 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -469,7 +469,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
@@ -619,8 +619,8 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
@@ -634,6 +634,9 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -743,7 +746,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"OK: 授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -771,7 +774,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
|
||||
@@ -63,5 +63,9 @@
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
|
||||
},
|
||||
"okr": {
|
||||
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
|
||||
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCopy = common.Shortcut{
|
||||
Command: "+base-copy",
|
||||
Description: "Copy a base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:copy"},
|
||||
UserScopes: []string{"base:app:copy"},
|
||||
BotScopes: []string{"base:app:copy", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
@@ -14,7 +14,8 @@ var BaseBaseCreate = common.Shortcut{
|
||||
Command: "+base-create",
|
||||
Description: "Create a new base resource",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:app:create"},
|
||||
UserScopes: []string{"base:app:create"},
|
||||
BotScopes: []string{"base:app:create", "docs:permission.member:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "base name", Required: true},
|
||||
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -19,12 +20,16 @@ import (
|
||||
)
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
|
||||
}
|
||||
|
||||
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
||||
return factory, stdout, reg
|
||||
@@ -48,7 +53,14 @@ func withBaseWorkingDir(t *testing.T, dir string) {
|
||||
|
||||
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
shortcut.AuthTypes = []string{"bot"}
|
||||
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
|
||||
}
|
||||
|
||||
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
if authTypes != nil {
|
||||
shortcut.AuthTypes = authTypes
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
@@ -60,6 +72,14 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
@@ -68,11 +88,32 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "app_token"); got != "app_x" {
|
||||
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +138,14 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
@@ -105,14 +154,243 @@ func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
|
||||
},
|
||||
})
|
||||
reg.Register(permStub)
|
||||
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
|
||||
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) {
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["copied"] != true {
|
||||
t.Fatalf("copied = %#v, want true", data["copied"])
|
||||
}
|
||||
base, _ := data["base"].(map[string]interface{})
|
||||
if got := common.GetString(base, "base_token"); got != "app_new" {
|
||||
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
|
||||
}
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, permStub)
|
||||
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
||||
t.Fatalf("unexpected permission request body: %#v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if _, ok := grant["user_open_id"]; ok {
|
||||
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
||||
t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_src/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
t.Run("create bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy bot", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create user", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func decodeBaseEnvelope(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("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
|
||||
@@ -17,36 +17,24 @@ func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr
|
||||
}
|
||||
|
||||
func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if runtime.Bool("without-content") {
|
||||
body["without_content"] = true
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy").
|
||||
Body(body).
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases").
|
||||
Body(body)
|
||||
Body(buildBaseCreateBody(runtime))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
@@ -59,6 +47,28 @@ func executeBaseGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, buildBaseCopyBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "copied": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, buildBaseCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"base": data, "created": true}
|
||||
augmentBasePermissionGrant(runtime, out, data)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
body["name"] = name
|
||||
@@ -72,15 +82,10 @@ func executeBaseCopy(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
func buildBaseCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
@@ -88,10 +93,20 @@ func executeBaseCreate(runtime *common.RuntimeContext) error {
|
||||
if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" {
|
||||
body["time_zone"] = timeZone
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"base": data, "created": true}, nil)
|
||||
return nil
|
||||
return body
|
||||
}
|
||||
|
||||
func augmentBasePermissionGrant(runtime *common.RuntimeContext, out, base map[string]interface{}) {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, extractBasePermissionToken(base), "bitable"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
func extractBasePermissionToken(base map[string]interface{}) string {
|
||||
for _, key := range []string{"base_token", "app_token"} {
|
||||
if token := strings.TrimSpace(common.GetString(base, key)); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ var alignMap = map[string]int{
|
||||
"right": 3,
|
||||
}
|
||||
|
||||
// fileViewMap maps the user-facing --file-view value to the docx File block
|
||||
// `view_type` enum. The underlying values come from the open platform spec:
|
||||
//
|
||||
// 1 = card view (default)
|
||||
// 2 = preview view (renders audio/video files as an inline player)
|
||||
// 3 = inline view
|
||||
var fileViewMap = map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
|
||||
var DocMediaInsert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+media-insert",
|
||||
@@ -33,6 +45,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
{Name: "type", Default: "image", Desc: "type: image | file"},
|
||||
{Name: "align", Desc: "alignment: left | center | right"},
|
||||
{Name: "caption", Desc: "image caption text"},
|
||||
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -42,6 +55,14 @@ var DocMediaInsert = common.Shortcut{
|
||||
if docRef.Kind == "doc" {
|
||||
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
|
||||
}
|
||||
if view := runtime.Str("file-view"); view != "" {
|
||||
if _, ok := fileViewMap[view]; !ok {
|
||||
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
|
||||
}
|
||||
if runtime.Str("type") != "file" {
|
||||
return output.ErrValidation("--file-view only applies when --type=file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,9 +76,10 @@ var DocMediaInsert = common.Shortcut{
|
||||
filePath := runtime.Str("file")
|
||||
mediaType := runtime.Str("type")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
parentType := parentTypeForMediaType(mediaType)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0)
|
||||
createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
|
||||
createBlockData["index"] = "<children_len>"
|
||||
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
|
||||
|
||||
@@ -92,6 +114,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
mediaType := runtime.Str("type")
|
||||
alignStr := runtime.Str("align")
|
||||
caption := runtime.Str("caption")
|
||||
fileViewType := fileViewMap[runtime.Str("file-view")]
|
||||
|
||||
documentID, err := resolveDocxDocumentID(runtime, docInput)
|
||||
if err != nil {
|
||||
@@ -132,7 +155,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
|
||||
createData, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
|
||||
nil, buildCreateBlockData(mediaType, insertIndex))
|
||||
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,12 +231,22 @@ func parentTypeForMediaType(mediaType string) string {
|
||||
return "docx_image"
|
||||
}
|
||||
|
||||
func buildCreateBlockData(mediaType string, index int) map[string]interface{} {
|
||||
func buildCreateBlockData(mediaType string, index int, fileViewType int) map[string]interface{} {
|
||||
child := map[string]interface{}{
|
||||
"block_type": blockTypeForMediaType(mediaType),
|
||||
}
|
||||
if mediaType == "file" {
|
||||
child["file"] = map[string]interface{}{}
|
||||
fileData := map[string]interface{}{}
|
||||
// view_type can only be set at block creation time; the PATCH
|
||||
// replace_file endpoint does not accept it, so if the caller wants
|
||||
// preview/inline rendering we must wire it in here. Whitelist the
|
||||
// concrete enum values so a stray positive int cannot produce a
|
||||
// malformed payload if Validate is ever bypassed.
|
||||
switch fileViewType {
|
||||
case 1, 2, 3:
|
||||
fileData["view_type"] = fileViewType
|
||||
}
|
||||
child["file"] = fileData
|
||||
} else {
|
||||
child["image"] = map[string]interface{}{}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 3)
|
||||
got := buildCreateBlockData("image", 3, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -29,7 +35,7 @@ func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
||||
func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 1)
|
||||
got := buildCreateBlockData("file", 1, 0)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -44,6 +50,113 @@ func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The `--file-view card` path sends a different request shape than
|
||||
// omitting the flag entirely: omitting produces `file: {}`, while
|
||||
// `card` produces `file: {view_type: 1}`. The two are intended to be
|
||||
// semantically equivalent at the API level, but the on-the-wire payload
|
||||
// is different and is part of the public flag contract, so pin it down.
|
||||
func TestBuildCreateBlockDataForFileWithCardView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 1) // card
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, card) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithPreviewView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 2) // preview
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBlockDataForFileWithInlineView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("file", 0, 3) // inline
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 23,
|
||||
"file": map[string]interface{}{
|
||||
"view_type": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(file, inline) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// view_type must never leak into non-file blocks even if the caller
|
||||
// accidentally passes a non-zero fileViewType alongside --type=image.
|
||||
func TestBuildCreateBlockDataForImageIgnoresFileViewType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildCreateBlockData("image", 0, 2)
|
||||
want := map[string]interface{}{
|
||||
"children": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 27,
|
||||
"image": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"index": 0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateBlockData(image, preview) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileViewMapCoversDocumentedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Assert only the documented keys — leave room for future aliases
|
||||
// (e.g. a "player" synonym for preview) without breaking this test.
|
||||
want := map[string]int{
|
||||
"card": 1,
|
||||
"preview": 2,
|
||||
"inline": 3,
|
||||
}
|
||||
for key, expected := range want {
|
||||
got, ok := fileViewMap[key]
|
||||
if !ok {
|
||||
t.Errorf("fileViewMap missing required key %q", key)
|
||||
continue
|
||||
}
|
||||
if got != expected {
|
||||
t.Errorf("fileViewMap[%q] = %d, want %d", key, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -161,3 +274,98 @@ func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) {
|
||||
t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner")
|
||||
}
|
||||
}
|
||||
|
||||
// newMediaInsertValidateRuntime builds a bare RuntimeContext wired with
|
||||
// only the flags that DocMediaInsert.Validate reads. It exists so the
|
||||
// Validate tests below can exercise the CLI contract without going
|
||||
// through the full cobra command tree.
|
||||
func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-insert"}
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("file-view", "", "")
|
||||
if err := cmd.Flags().Set("doc", doc); err != nil {
|
||||
t.Fatalf("set --doc: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if fileView != "" {
|
||||
if err := cmd.Flags().Set("file-view", fileView); err != nil {
|
||||
t.Fatalf("set --file-view: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// Validate is the real user-facing contract for --file-view: unknown
|
||||
// values must be rejected, and passing the flag alongside --type!=file
|
||||
// must also be rejected. buildCreateBlockData tests alone cannot catch
|
||||
// regressions here, so lock the guard logic down explicitly.
|
||||
func TestDocMediaInsertValidateFileView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaType string
|
||||
fileView string
|
||||
wantErr string // substring; empty means success expected
|
||||
}{
|
||||
{
|
||||
name: "file with card is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "card",
|
||||
},
|
||||
{
|
||||
name: "file with preview is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "preview",
|
||||
},
|
||||
{
|
||||
name: "file with inline is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "inline",
|
||||
},
|
||||
{
|
||||
name: "file without file-view is accepted",
|
||||
mediaType: "file",
|
||||
fileView: "",
|
||||
},
|
||||
{
|
||||
name: "unknown file-view value is rejected",
|
||||
mediaType: "file",
|
||||
fileView: "bogus",
|
||||
wantErr: "invalid --file-view value",
|
||||
},
|
||||
{
|
||||
name: "file-view with image type is rejected",
|
||||
mediaType: "image",
|
||||
fileView: "preview",
|
||||
wantErr: "--file-view only applies when --type=file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, ttTemp := range tests {
|
||||
tt := ttTemp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newMediaInsertValidateRuntime(t, "doxcnValidateFileView", tt.mediaType, tt.fileView)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -63,7 +64,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)",
|
||||
Description: "Add a full-document or local comment to doc/docx/sheet, also supports wiki URL resolving to doc/docx/sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"docx:document:readonly",
|
||||
@@ -72,14 +73,15 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true},
|
||||
{Name: "doc", Desc: "document URL/token, sheet URL, or wiki URL that resolves to doc/docx/sheet", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, sheet (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +90,21 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
if _, err := parseSheetCellRef(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -99,37 +116,69 @@ var DriveAddComment = common.Shortcut{
|
||||
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment")
|
||||
return output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"))
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
replyElements, _ := parseCommentReplyElements(runtime.Str("content"))
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
|
||||
// For wiki URLs, resolve the actual target type via API so dry-run
|
||||
// matches real execution behavior instead of guessing from --block-id.
|
||||
resolvedKind := docRef.Kind
|
||||
resolvedToken := docRef.Token
|
||||
isWiki := false
|
||||
if docRef.Kind == "wiki" {
|
||||
isWiki = true
|
||||
target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), commentModeFull)
|
||||
if err == nil {
|
||||
resolvedKind = target.FileType
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
if anchor == nil {
|
||||
anchor = &sheetAnchor{SheetID: "<sheetId>", Col: 0, Row: 0}
|
||||
}
|
||||
commentBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
desc := "1-step request: create sheet comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create sheet comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Doc/docx comment dry-run.
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
|
||||
targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode)
|
||||
|
||||
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
|
||||
commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements)
|
||||
commentBody := buildCommentCreateV2Request(resolvedKind, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements)
|
||||
commentBody = buildCommentCreateV2Request(resolvedKind, anchorBlockIDForDryRun(blockID), replyElements, nil)
|
||||
}
|
||||
|
||||
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
switch {
|
||||
case mode == commentModeFull && resolvedBy == "wiki":
|
||||
case mode == commentModeFull && isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create full comment")
|
||||
case mode == commentModeFull:
|
||||
dry.Desc("1-step request: create full comment")
|
||||
case resolvedBy == "wiki" && strings.TrimSpace(selection) != "":
|
||||
case isWiki && strings.TrimSpace(selection) != "":
|
||||
dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment")
|
||||
case resolvedBy == "wiki":
|
||||
case isWiki:
|
||||
dry.Desc("2-step orchestration: resolve wiki -> create local comment")
|
||||
case strings.TrimSpace(selection) != "":
|
||||
dry.Desc("2-step orchestration: locate block -> create local comment")
|
||||
@@ -137,19 +186,17 @@ var DriveAddComment = common.Shortcut{
|
||||
dry.Desc("1-step request: create local comment with explicit block ID")
|
||||
}
|
||||
|
||||
if resolvedBy == "wiki" {
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to target document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
|
||||
if mode == commentModeLocal && strings.TrimSpace(selection) != "" {
|
||||
step := "[1]"
|
||||
if resolvedBy == "wiki" {
|
||||
if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
docID := resolvedToken
|
||||
if isWiki && resolvedToken == docRef.Token {
|
||||
docID = "<resolved_docx_token>"
|
||||
}
|
||||
mcpArgs := map[string]interface{}{
|
||||
"doc_id": dryRunLocateDocRef(docRef),
|
||||
"doc_id": docID,
|
||||
"limit": defaultLocateDocLimit,
|
||||
"selection_with_ellipsis": selection,
|
||||
}
|
||||
@@ -171,23 +218,29 @@ var DriveAddComment = common.Shortcut{
|
||||
if mode == commentModeLocal {
|
||||
createDesc = "Create local comment"
|
||||
step = "[2]"
|
||||
if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" {
|
||||
if isWiki && strings.TrimSpace(selection) != "" {
|
||||
step = "[3]"
|
||||
} else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" {
|
||||
} else if isWiki || strings.TrimSpace(selection) != "" {
|
||||
step = "[2]"
|
||||
} else {
|
||||
step = "[1]"
|
||||
}
|
||||
} else if resolvedBy == "wiki" {
|
||||
} else if isWiki {
|
||||
step = "[2]"
|
||||
}
|
||||
|
||||
return dry.POST(createPath).
|
||||
Desc(step+" "+createDesc).
|
||||
Body(commentBody).
|
||||
Set("file_token", targetToken)
|
||||
Set("file_token", resolvedToken)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
@@ -197,6 +250,11 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Wiki resolved to sheet: redirect to sheet comment path.
|
||||
if target.FileType == "sheet" {
|
||||
return executeSheetComment(runtime, commentDocRef{Kind: "sheet", Token: target.FileToken})
|
||||
}
|
||||
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,9 +283,9 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements)
|
||||
requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements, nil)
|
||||
if mode == commentModeLocal {
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements)
|
||||
requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements, nil)
|
||||
}
|
||||
|
||||
if mode == commentModeLocal {
|
||||
@@ -288,7 +346,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
|
||||
return commentModeLocal
|
||||
}
|
||||
|
||||
func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
@@ -297,6 +355,9 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/wiki/"); ok {
|
||||
return commentDocRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/docx/"); ok {
|
||||
return commentDocRef{Kind: "docx", Token: token}, nil
|
||||
}
|
||||
@@ -304,40 +365,29 @@ func parseCommentDocRef(input string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet", raw)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
}
|
||||
|
||||
return commentDocRef{Kind: "docx", Token: raw}, nil
|
||||
}
|
||||
|
||||
func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) {
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, "docx", "docx"
|
||||
case "doc":
|
||||
return docRef.Token, "doc", "doc"
|
||||
case "wiki":
|
||||
if mode == commentModeFull {
|
||||
return "<resolved_file_token>", "<resolved_file_type>", "wiki"
|
||||
}
|
||||
return "<resolved_docx_token>", "docx", "wiki"
|
||||
default:
|
||||
return "<resolved_docx_token>", "docx", "docx"
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) {
|
||||
docRef, err := parseCommentDocRef(input)
|
||||
docRef, err := parseCommentDocRef(input, runtime.Str("type"))
|
||||
if err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents")
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx and sheet; old doc format only supports full comments")
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -364,11 +414,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "" || objToken == "" {
|
||||
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "sheet",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx and sheet; for sheet use --block-id <sheetId>!<cell>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -531,12 +592,24 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
return replyElements, nil
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} {
|
||||
type sheetAnchor struct {
|
||||
SheetID string
|
||||
Col int
|
||||
Row int
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"reply_elements": replyElements,
|
||||
}
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
if sheet != nil {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": sheet.SheetID,
|
||||
"sheet_col": sheet.Col,
|
||||
"sheet_row": sheet.Row,
|
||||
}
|
||||
} else if strings.TrimSpace(blockID) != "" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
}
|
||||
@@ -551,13 +624,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func dryRunLocateDocRef(docRef commentDocRef) string {
|
||||
if docRef.Kind == "wiki" {
|
||||
return "<resolved_docx_token>"
|
||||
}
|
||||
return docRef.Token
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -576,6 +642,83 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSheetCellRef parses "<sheetId>!<cell>" (e.g. "a281f9!D6") into a sheetAnchor.
|
||||
// Column is converted from letter to 0-based index (A=0), row from 1-based to 0-based.
|
||||
func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
parts := strings.SplitN(input, "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
|
||||
}
|
||||
sheetID := parts[0]
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
|
||||
// Parse cell reference like "D6" into col letter + row number.
|
||||
i := 0
|
||||
for i < len(cell) && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(cell) {
|
||||
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
|
||||
}
|
||||
colStr := strings.ToUpper(cell[:i])
|
||||
rowStr := cell[i:]
|
||||
|
||||
// Column letter to 0-based index: A=0, B=1, ..., Z=25, AA=26.
|
||||
col := 0
|
||||
for _, ch := range colStr {
|
||||
col = col*26 + int(ch-'A'+1)
|
||||
}
|
||||
col-- // convert to 0-based
|
||||
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil || row < 1 {
|
||||
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
|
||||
}
|
||||
row-- // convert to 0-based
|
||||
|
||||
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
anchor, err := parseSheetCellRef(blockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(docRef.Token))
|
||||
requestBody := buildCommentCreateV2Request("sheet", "", replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
|
||||
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
|
||||
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"comment_id": data["comment_id"],
|
||||
"file_token": docRef.Token,
|
||||
"file_type": "sheet",
|
||||
"comment_mode": "sheet",
|
||||
"block_id": blockID,
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractURLToken(raw, marker string) (string, bool) {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -6,6 +6,9 @@ package drive
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseCommentDocRef(t *testing.T) {
|
||||
@@ -14,6 +17,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
docType string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
@@ -31,11 +35,31 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token treated as docx",
|
||||
name: "raw token with type docx",
|
||||
input: "xxxxxx",
|
||||
docType: "docx",
|
||||
wantKind: "docx",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "raw token with type sheet",
|
||||
input: "shtToken",
|
||||
docType: "sheet",
|
||||
wantKind: "sheet",
|
||||
wantToken: "shtToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type doc",
|
||||
input: "docToken",
|
||||
docType: "doc",
|
||||
wantKind: "doc",
|
||||
wantToken: "docToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
wantErr: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "old doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -53,7 +77,7 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentDocRef(tt.input)
|
||||
got, err := parseCommentDocRef(tt.input, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
@@ -249,7 +273,7 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
|
||||
"text": "全文评论",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -279,7 +303,7 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
"text": "评论内容",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements)
|
||||
got := buildCommentCreateV2Request("docx", "blk_123", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "docx" {
|
||||
t.Fatalf("expected file_type docx, got %#v", got["file_type"])
|
||||
@@ -300,3 +324,540 @@ func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
t.Fatalf("unexpected reply element: %#v", gotReplyElements[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment tests ─────────────────────────────────────────────────────
|
||||
|
||||
func TestParseCommentDocRefSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefSheetWithQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseCommentDocRef("https://example.larksuite.com/sheets/shtToken123?sheet=abc", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != "sheet" || ref.Token != "shtToken123" {
|
||||
t.Fatalf("expected sheet/shtToken123, got %s/%s", ref.Kind, ref.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "请修正此单元格"},
|
||||
}
|
||||
got := buildCommentCreateV2Request("sheet", "", replyElements, &sheetAnchor{
|
||||
SheetID: "abc123",
|
||||
Col: 3,
|
||||
Row: 5,
|
||||
})
|
||||
|
||||
if got["file_type"] != "sheet" {
|
||||
t.Fatalf("expected file_type sheet, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "abc123" {
|
||||
t.Fatalf("expected block_id abc123, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["sheet_col"] != 3 {
|
||||
t.Fatalf("expected sheet_col 3, got %#v", anchor["sheet_col"])
|
||||
}
|
||||
if anchor["sheet_row"] != 5 {
|
||||
t.Fatalf("expected sheet_row 5, got %#v", anchor["sheet_row"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "test"},
|
||||
}
|
||||
// When both sheet anchor and blockID are provided, sheet anchor wins.
|
||||
got := buildCommentCreateV2Request("sheet", "should_be_ignored", replyElements, &sheetAnchor{
|
||||
SheetID: "s1",
|
||||
Col: 0,
|
||||
Row: 0,
|
||||
})
|
||||
anchor := got["anchor"].(map[string]interface{})
|
||||
if anchor["block_id"] != "s1" {
|
||||
t.Fatalf("expected sheet anchor block_id, got %#v", anchor["block_id"])
|
||||
}
|
||||
if _, exists := anchor["sheet_col"]; !exists {
|
||||
t.Fatal("expected sheet_col in anchor")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
sheetID string
|
||||
col int
|
||||
row int
|
||||
}{
|
||||
{"A1", "s1!A1", "s1", 0, 0},
|
||||
{"D6", "abc!D6", "abc", 3, 5},
|
||||
{"AA1", "s1!AA1", "s1", 26, 0},
|
||||
{"lowercase", "s1!d6", "s1", 3, 5},
|
||||
{"B10", "sheet1!B10", "sheet1", 1, 9},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseSheetCellRef(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SheetID != tc.sheetID || got.Col != tc.col || got.Row != tc.row {
|
||||
t.Fatalf("expected {%s %d %d}, got {%s %d %d}", tc.sheetID, tc.col, tc.row, got.SheetID, got.Col, got.Row)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{"", "noExclamation", "s1!", "!A1", "s1!123", "s1!A"}
|
||||
for _, input := range cases {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef(input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment validate tests ────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "no-exclamation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<sheetId>!<cell>") {
|
||||
t.Fatalf("expected format error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--full-comment",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--selection-with-ellipsis", "something",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applicable for sheet") {
|
||||
t.Fatalf("expected incompatible flags error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet comment execute tests ─────────────────────────────────────────────
|
||||
|
||||
func TestSheetCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "comment123", "created_at": 1700000000},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"请检查"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "comment123") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtFromURL/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "c456"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtFromURL?sheet=abc",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "abc!A1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiResolve(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiSheetComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"wiki sheet comment"}]`,
|
||||
"--block-id", "s1!B3",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiSheetComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": "shtResolved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken123",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun coverage ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDryRunSheetDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSheet(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "sheet", "obj_token": "shtResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!D6",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "sheet comment") {
|
||||
t.Fatalf("dry-run output missing sheet comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToDocxFull(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxLocalWithBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "local comment") {
|
||||
t.Fatalf("dry-run output missing local comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/docx/docxToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "full comment") {
|
||||
t.Fatalf("dry-run output missing full comment: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveCommentTarget coverage ───────────────────────────────────────────
|
||||
|
||||
func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docxResolved"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/docxResolved/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiDocxComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiDocxComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiIncompleteNodeData(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
|
||||
t.Fatalf("expected incomplete node error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/doc/oldDocToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx and sheet") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Additional unit function tests ──────────────────────────────────────────
|
||||
|
||||
func TestAnchorBlockIDForDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := anchorBlockIDForDryRun("blk_123"); got != "blk_123" {
|
||||
t.Fatalf("expected blk_123, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(""); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder, got %s", got)
|
||||
}
|
||||
if got := anchorBlockIDForDryRun(" "); got != "<anchor_block_id>" {
|
||||
t.Fatalf("expected placeholder for whitespace, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSheetCellRefRowZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseSheetCellRef("s1!A0")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be >= 1") {
|
||||
t.Fatalf("expected row validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentDocRefPathLikeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseCommentDocRef("token/with/slash", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --doc input") {
|
||||
t.Fatalf("expected unsupported doc error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
|
||||
if ok {
|
||||
t.Fatal("expected false for empty token after marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCommentExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/shtToken/new_comments",
|
||||
Status: 400, Body: map[string]interface{}{"code": 1061002, "msg": "params error"},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/sheets/shtToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "s1!A1",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,12 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st
|
||||
return err
|
||||
}
|
||||
|
||||
func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) {
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil)
|
||||
func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
|
||||
var bodyParams map[string]interface{}
|
||||
if sendTime != "" {
|
||||
bodyParams = map[string]interface{}{"send_time": sendTime}
|
||||
}
|
||||
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
|
||||
}
|
||||
|
||||
func extractDraftID(data map[string]interface{}) string {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -1162,6 +1163,7 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
|
||||
out["date_formatted"] = normalized.DateFormatted
|
||||
out["message_state_text"] = normalized.MessageStateText
|
||||
if normalized.PriorityType != "" {
|
||||
out["priority_type"] = normalized.PriorityType
|
||||
out["priority_type_text"] = normalized.PriorityTypeText
|
||||
}
|
||||
out["body_plain_text"] = normalized.BodyPlainText
|
||||
@@ -1240,11 +1242,22 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
|
||||
out.MessageStateText = messageStateText(state)
|
||||
out.FolderID = strVal(msg["folder_id"])
|
||||
out.LabelIDs = toStringList(msg["label_ids"])
|
||||
// Priority: prefer label_ids (HIGH_PRIORITY/LOW_PRIORITY), fall back to priority_type field.
|
||||
priorityType := strVal(msg["priority_type"])
|
||||
out.PriorityType = priorityType
|
||||
if priorityType != "" {
|
||||
out.PriorityTypeText = priorityTypeText(priorityType)
|
||||
}
|
||||
for _, label := range out.LabelIDs {
|
||||
switch label {
|
||||
case "HIGH_PRIORITY":
|
||||
out.PriorityType = "1"
|
||||
out.PriorityTypeText = "high"
|
||||
case "LOW_PRIORITY":
|
||||
out.PriorityType = "5"
|
||||
out.PriorityTypeText = "low"
|
||||
}
|
||||
}
|
||||
if securityLevel := toSecurityLevel(msg["security_level"]); securityLevel != nil {
|
||||
out.SecurityLevel = securityLevel
|
||||
}
|
||||
@@ -1707,6 +1720,48 @@ func priorityTypeText(priorityType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// priorityFlag is the common flag definition for --priority, shared by all compose shortcuts.
|
||||
var priorityFlag = common.Flag{
|
||||
Name: "priority",
|
||||
Desc: "Email priority: high, normal, low. If omitted, no priority header is set.",
|
||||
}
|
||||
|
||||
// parsePriority parses the --priority flag value and returns the X-Cli-Priority
|
||||
// header value. Returns "" if the priority should not be set (empty or "normal").
|
||||
func parsePriority(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return "", nil
|
||||
case "high":
|
||||
return "1", nil
|
||||
case "normal":
|
||||
return "", nil
|
||||
case "low":
|
||||
return "5", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --priority value %q: expected high, normal, or low", value)
|
||||
}
|
||||
}
|
||||
|
||||
// validatePriorityFlag validates the --priority flag value in Validate, so invalid
|
||||
// values are caught before Execute (and before dry-run prints an API plan).
|
||||
func validatePriorityFlag(runtime *common.RuntimeContext) error {
|
||||
v := runtime.Str("priority")
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := parsePriority(v)
|
||||
return err
|
||||
}
|
||||
|
||||
// applyPriority sets the X-Cli-Priority header on the EML builder if priority is non-empty.
|
||||
func applyPriority(bld emlbuilder.Builder, priority string) emlbuilder.Builder {
|
||||
if priority == "" {
|
||||
return bld
|
||||
}
|
||||
return bld.Header("X-Cli-Priority", priority)
|
||||
}
|
||||
|
||||
// parseNetAddrs converts a comma-separated address string to []net/mail.Address.
|
||||
// It reuses ParseMailboxList for display-name-aware parsing and deduplicates
|
||||
// by email address (case-insensitive), preserving the first occurrence.
|
||||
@@ -1906,6 +1961,27 @@ func checkAttachmentSizeLimit(fio fileio.FileIO, filePaths []string, extraBytes
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
|
||||
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
|
||||
func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
sendTime := runtime.Str("send-time")
|
||||
if sendTime == "" {
|
||||
return nil
|
||||
}
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return fmt.Errorf("--send-time requires --confirm-send to be set")
|
||||
}
|
||||
ts, err := strconv.ParseInt(sendTime, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
|
||||
}
|
||||
minTime := time.Now().Unix() + 5*60
|
||||
if ts < minTime {
|
||||
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfirmSendScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:send scope when --confirm-send is set.
|
||||
// This scope is not declared in the shortcut's static Scopes (to keep the
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -1006,3 +1008,212 @@ func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateSendTime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("send-time", "", "")
|
||||
cmd.Flags().Bool("confirm-send", false, "")
|
||||
if sendTime != "" {
|
||||
_ = cmd.Flags().Set("send-time", sendTime)
|
||||
}
|
||||
if confirmSend {
|
||||
_ = cmd.Flags().Set("confirm-send", "true")
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_Empty(t *testing.T) {
|
||||
rt := newSendTimeRuntime(t, "", false)
|
||||
if err := validateSendTime(rt); err != nil {
|
||||
t.Fatalf("expected nil when send-time is empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_RequiresConfirmSend(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
rt := newSendTimeRuntime(t, future, false)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is set without --confirm-send")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--confirm-send") {
|
||||
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_InvalidInteger(t *testing.T) {
|
||||
rt := newSendTimeRuntime(t, "not-a-number", true)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is not a valid integer")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unix timestamp") {
|
||||
t.Errorf("expected error to mention Unix timestamp, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_TooSoon(t *testing.T) {
|
||||
// Just 1 minute in the future — below the 5-minute minimum.
|
||||
soon := strconv.FormatInt(time.Now().Unix()+60, 10)
|
||||
rt := newSendTimeRuntime(t, soon, true)
|
||||
err := validateSendTime(rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --send-time is less than 5 minutes in the future")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected error to mention 5 minute minimum, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSendTime_Valid(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
rt := newSendTimeRuntime(t, future, true)
|
||||
if err := validateSendTime(rt); err != nil {
|
||||
t.Fatalf("expected nil for valid future send-time, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"high", "high", "1", false},
|
||||
{"normal", "normal", "", false},
|
||||
{"low", "low", "5", false},
|
||||
{"case-insensitive HIGH", "HIGH", "1", false},
|
||||
{"whitespace padding", " low ", "5", false},
|
||||
{"invalid", "urgent", "", true},
|
||||
{"numeric not accepted", "1", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parsePriority(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parsePriority(%q): expected error, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parsePriority(%q): unexpected error: %v", tc.input, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("parsePriority(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
labels []interface{}
|
||||
priorityType string
|
||||
wantType string
|
||||
wantText string
|
||||
}{
|
||||
{"high from label", []interface{}{"UNREAD", "HIGH_PRIORITY"}, "", "1", "high"},
|
||||
{"low from label", []interface{}{"LOW_PRIORITY"}, "", "5", "low"},
|
||||
{"no priority label", []interface{}{"UNREAD"}, "", "", ""},
|
||||
{"label overrides priority_type field", []interface{}{"HIGH_PRIORITY"}, "5", "1", "high"},
|
||||
{"priority_type fallback when no label", []interface{}{"UNREAD"}, "1", "1", "high"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
msg := map[string]interface{}{
|
||||
"message_id": "m1",
|
||||
"label_ids": tc.labels,
|
||||
}
|
||||
if tc.priorityType != "" {
|
||||
msg["priority_type"] = tc.priorityType
|
||||
}
|
||||
out := buildMessageOutput(msg, false)
|
||||
gotText, _ := out["priority_type_text"].(string)
|
||||
if gotText != tc.wantText {
|
||||
t.Errorf("priority_type_text = %q, want %q", gotText, tc.wantText)
|
||||
}
|
||||
gotType, _ := out["priority_type"].(string)
|
||||
if gotType != tc.wantType {
|
||||
t.Errorf("priority_type = %q, want %q", gotType, tc.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPriority(t *testing.T) {
|
||||
// Empty priority: EML must not contain X-Cli-Priority header.
|
||||
emptyBld := emlbuilder.New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("no priority").
|
||||
TextBody([]byte("body"))
|
||||
emptyBld = applyPriority(emptyBld, "")
|
||||
raw, err := emptyBld.BuildBase64URL()
|
||||
if err != nil {
|
||||
t.Fatalf("build EML failed: %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(raw)
|
||||
if strings.Contains(eml, "X-Cli-Priority") {
|
||||
t.Errorf("expected no X-Cli-Priority header when priority is empty, got EML:\n%s", eml)
|
||||
}
|
||||
|
||||
// Non-empty priority: header must be present with the exact value.
|
||||
highBld := emlbuilder.New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("high priority").
|
||||
TextBody([]byte("body"))
|
||||
highBld = applyPriority(highBld, "1")
|
||||
raw, err = highBld.BuildBase64URL()
|
||||
if err != nil {
|
||||
t.Fatalf("build EML failed: %v", err)
|
||||
}
|
||||
eml = decodeBase64URL(raw)
|
||||
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
||||
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePriorityFlag(t *testing.T) {
|
||||
makeRuntime := func(priority string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("priority", "", "")
|
||||
if priority != "" {
|
||||
_ = cmd.Flags().Set("priority", priority)
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
priority string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty ok", "", false},
|
||||
{"high ok", "high", false},
|
||||
{"normal ok", "normal", false},
|
||||
{"low ok", "low", false},
|
||||
{"invalid urgent", "urgent", true},
|
||||
{"invalid numeric", "1", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validatePriorityFlag(makeRuntime(tc.priority))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("validatePriorityFlag(%q): expected error, got nil", tc.priority)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("validatePriorityFlag(%q): unexpected error: %v", tc.priority, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
|
||||
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
@@ -79,19 +80,23 @@ var MailDraftCreate = common.Shortcut{
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult)
|
||||
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult, priority)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,7 +134,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) {
|
||||
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult, priority string) (string, error) {
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
@@ -190,6 +195,7 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -142,6 +142,40 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "priority test",
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if !strings.Contains(eml, "X-Cli-Priority: 1") {
|
||||
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "no priority",
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
eml := decodeBase64URL(rawEML)
|
||||
if strings.Contains(eml, "X-Cli-Priority") {
|
||||
t.Errorf("expected no X-Cli-Priority header when priority is empty, got:\n%s", eml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
@@ -153,7 +187,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
|
||||
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
|
||||
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
|
||||
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -276,6 +277,19 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
setRecipients("cc", runtime.Str("set-cc"))
|
||||
setRecipients("bcc", runtime.Str("set-bcc"))
|
||||
|
||||
// --set-priority → inject set_header / remove_header op
|
||||
if setPriority := runtime.Str("set-priority"); setPriority != "" {
|
||||
headerVal, pErr := parsePriority(setPriority)
|
||||
if pErr != nil {
|
||||
return patch, pErr
|
||||
}
|
||||
if headerVal != "" {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_header", Name: "X-Cli-Priority", Value: headerVal})
|
||||
} else {
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_header", Name: "X-Cli-Priority"})
|
||||
}
|
||||
}
|
||||
|
||||
if len(patch.Ops) == 0 {
|
||||
return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)")
|
||||
}
|
||||
|
||||
92
shortcuts/mail/mail_draft_edit_test.go
Normal file
92
shortcuts/mail/mail_draft_edit_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newDraftEditRuntime creates a minimal RuntimeContext with the draft-edit
|
||||
// flags used by buildDraftEditPatch.
|
||||
func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{
|
||||
"set-subject", "set-to", "set-cc", "set-bcc",
|
||||
"set-priority", "patch-file",
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, val := range flags {
|
||||
_ = cmd.Flags().Set(name, val)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityHigh(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "high"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
||||
}
|
||||
op := patch.Ops[0]
|
||||
if op.Op != "set_header" {
|
||||
t.Errorf("Op = %q, want set_header", op.Op)
|
||||
}
|
||||
if op.Name != "X-Cli-Priority" {
|
||||
t.Errorf("Name = %q, want X-Cli-Priority", op.Name)
|
||||
}
|
||||
if op.Value != "1" {
|
||||
t.Errorf("Value = %q, want 1", op.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityLow(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "low"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Value != "5" {
|
||||
t.Fatalf("expected single set_header with value 5, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_SetPriorityNormalClears(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "normal"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(patch.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
||||
}
|
||||
if patch.Ops[0].Op != "remove_header" || patch.Ops[0].Name != "X-Cli-Priority" {
|
||||
t.Errorf("expected remove_header X-Cli-Priority, got %+v", patch.Ops[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-priority": "urgent"})
|
||||
if _, err := buildDraftEditPatch(rt); err == nil {
|
||||
t.Fatal("expected error for invalid --set-priority value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
|
||||
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
|
||||
patch, err := buildDraftEditPatch(rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Only the set_subject op should be present; no priority op injected.
|
||||
if len(patch.Ops) != 1 || patch.Ops[0].Op != "set_subject" {
|
||||
t.Errorf("expected single set_subject op, got %+v", patch.Ops)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ var MailForward = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
@@ -59,6 +61,9 @@ var MailForward = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("confirm-send") {
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
@@ -67,7 +72,10 @@ var MailForward = common.Shortcut{
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -79,6 +87,12 @@ var MailForward = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
@@ -169,6 +183,7 @@ var MailForward = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
// Download original attachments and accumulate size for limit check
|
||||
type downloadedAtt struct {
|
||||
content []byte
|
||||
@@ -231,7 +246,7 @@ var MailForward = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ var MailReply = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -56,10 +58,16 @@ var MailReply = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -71,6 +79,12 @@ var MailReply = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -170,6 +184,7 @@ var MailReply = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -194,7 +209,7 @@ var MailReply = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -57,10 +59,16 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -73,6 +81,12 @@ var MailReplyAll = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
@@ -184,6 +198,7 @@ var MailReplyAll = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -208,7 +223,7 @@ var MailReplyAll = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ var MailSend = common.Shortcut{
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
signatureFlag},
|
||||
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
|
||||
signatureFlag,
|
||||
priorityFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
@@ -62,9 +64,15 @@ var MailSend = common.Shortcut{
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePriorityFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -77,9 +85,14 @@ var MailSend = common.Shortcut{
|
||||
attachFlag := runtime.Str("attach")
|
||||
inlineFlag := runtime.Str("inline")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
sendTime := runtime.Str("send-time")
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
signatureID := runtime.Str("signature-id")
|
||||
priority, err := parsePriority(runtime.Str("priority"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
|
||||
@@ -136,6 +149,7 @@ var MailSend = common.Shortcut{
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(body))
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(runtime.FileIO(), allFilePaths, 0); err != nil {
|
||||
return err
|
||||
@@ -162,7 +176,7 @@ var MailSend = common.Shortcut{
|
||||
hintSendDraft(runtime, mailboxID, draftID)
|
||||
return nil
|
||||
}
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID)
|
||||
resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
|
||||
}
|
||||
|
||||
135
shortcuts/mail/mail_send_time_integration_test.go
Normal file
135
shortcuts/mail/mail_send_time_integration_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// mailShortcutTestFactoryWithSendScope mirrors mailShortcutTestFactory but
|
||||
// additionally grants the mail:user_mailbox.message:send scope so tests can
|
||||
// exercise code paths guarded by validateConfirmSendScope (e.g. validateSendTime).
|
||||
func mailShortcutTestFactoryWithSendScope(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
cfg := mailTestConfig()
|
||||
token := &auth.StoredUAToken{
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AppId: cfg.AppID,
|
||||
AccessToken: "test-user-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
})
|
||||
return cmdutil.TestFactory(t, cfg)
|
||||
}
|
||||
|
||||
// tooSoonSendTime returns a send-time 60s in the future — below the 5-minute
|
||||
// floor enforced by validateSendTime.
|
||||
func tooSoonSendTime() string {
|
||||
return strconv.FormatInt(time.Now().Unix()+60, 10)
|
||||
}
|
||||
|
||||
// futureSendTime returns a send-time 10 minutes in the future — above the floor.
|
||||
func futureSendTime() string {
|
||||
return strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invalid --send-time rejected by each compose shortcut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMailSend_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReply_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReplyAll_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all", "--message-id", "msg_001", "--body", "hello",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailForward_SendTimeTooSoon(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001", "--to", "alice@example.com",
|
||||
"--confirm-send", "--send-time", tooSoonSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-soon send-time, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "5 minutes") {
|
||||
t.Errorf("expected 5-minute error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --send-time without --confirm-send is rejected up front
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMailSend_SendTimeWithoutConfirmSend(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello",
|
||||
"--send-time", futureSendTime(),
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --send-time without --confirm-send, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--confirm-send") {
|
||||
t.Errorf("expected error to mention --confirm-send, got: %v", err)
|
||||
}
|
||||
}
|
||||
101
shortcuts/okr/okr_cli_resp.go
Normal file
101
shortcuts/okr/okr_cli_resp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
// RespAlignment 对齐关系
|
||||
type RespAlignment struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
FromOwner RespOwner `json:"from_owner"`
|
||||
ToOwner RespOwner `json:"to_owner"`
|
||||
FromEntityType string `json:"from_entity_type"`
|
||||
FromEntityID string `json:"from_entity_id"`
|
||||
ToEntityType string `json:"to_entity_type"`
|
||||
ToEntityID string `json:"to_entity_id"`
|
||||
}
|
||||
|
||||
// RespCategory 分类
|
||||
type RespCategory struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CategoryType string `json:"category_type"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Name CategoryName `json:"name"`
|
||||
}
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
type RespIndicator struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
EntityType *string `json:"entity_type,omitempty"`
|
||||
EntityID *string `json:"entity_id,omitempty"`
|
||||
IndicatorStatus *string `json:"indicator_status,omitempty"`
|
||||
StatusCalculateType *string `json:"status_calculate_type,omitempty"`
|
||||
StartValue *float64 `json:"start_value,omitempty"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CurrentValue *float64 `json:"current_value,omitempty"`
|
||||
CurrentValueCalculateType *string `json:"current_value_calculate_type,omitempty"`
|
||||
Unit *RespIndicatorUnit `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicatorUnit 指标单位
|
||||
type RespIndicatorUnit struct {
|
||||
UnitType *string `json:"unit_type,omitempty"`
|
||||
UnitValue *string `json:"unit_value,omitempty"`
|
||||
}
|
||||
|
||||
// RespKeyResult 关键结果
|
||||
type RespKeyResult 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 *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjective 目标
|
||||
type RespObjective 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 *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResult `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespOwner OKR 所有者
|
||||
type RespOwner struct {
|
||||
OwnerType string `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
182
shortcuts/okr/okr_cycle_detail.go
Normal file
182
shortcuts/okr/okr_cycle_detail.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
|
||||
var OKRCycleDetail = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-detail",
|
||||
Description: "List objectives and key results under an OKR cycle",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.content:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return common.FlagErrorf("--cycle-id is required")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--cycle-id must be a positive int64")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
params := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Params(params).
|
||||
Set("cycle_id", cycleID).
|
||||
Desc("Auto-paginates objectives in the cycle, then calls GET /open-apis/okr/v2/objectives/:objective_id/key_results for each objective to fetch key results")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var obj Objective
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
objectives = append(objectives, obj)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := make(larkcore.QueryParams)
|
||||
krQuery.Set("page_size", "100")
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
krQuery.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
@@ -0,0 +1,561 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleDetailTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-detail-" + suffix,
|
||||
AppSecret: "secret-okr-detail-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleDetailShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCycleDetail.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func decodeEnvelope(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("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleDetailValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --cycle-id")
|
||||
}
|
||||
// cobra catches required flag before our Validate runs
|
||||
if !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_NonNumeric(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Zero(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for zero --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
// Need to register stubs because Validate passes and Execute runs
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "123"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleDetailDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{
|
||||
"+cycle-detail",
|
||||
"--cycle-id", "456",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "456") {
|
||||
t.Fatalf("dry-run output should contain cycle-id 456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/456/objectives") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleDetailExecute_NoObjectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/100/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "100"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "100" {
|
||||
t.Fatalf("cycle_id = %v, want 100", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 0 {
|
||||
t.Fatalf("objectives = %v, want empty", objs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_WithObjectivesAndKeyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// Stub for objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/200/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "200",
|
||||
"score": 0.8,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Improve team productivity",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Stub for key results of obj-1
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-1",
|
||||
"objective_id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Reduce response time by 50%",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "200"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "200" {
|
||||
t.Fatalf("cycle_id = %v, want 200", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("objectives count = %d, want 1", len(objs))
|
||||
}
|
||||
obj, _ := objs[0].(map[string]interface{})
|
||||
if obj["id"] != "obj-1" {
|
||||
t.Fatalf("objective id = %v, want obj-1", obj["id"])
|
||||
}
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 1 {
|
||||
t.Fatalf("key results count = %d, want 1", len(krs))
|
||||
}
|
||||
kr, _ := krs[0].(map[string]interface{})
|
||||
if kr["id"] != "kr-1" {
|
||||
t.Fatalf("key result id = %v, want kr-1", kr["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// First page of objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.5,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page1 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page of objectives (no more)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.6,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page2 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p1: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-1",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.7,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p1-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p1: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-2",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.8,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p2: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-1",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.6,
|
||||
"weight": 0.4,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p2-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p2: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-2",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.6,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "300"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 2 {
|
||||
t.Fatalf("objectives count = %d, want 2", len(objs))
|
||||
}
|
||||
|
||||
// Verify key_results are aggregated across pages for each objective
|
||||
for i, objRaw := range objs {
|
||||
obj, _ := objRaw.(map[string]interface{})
|
||||
objID, _ := obj["id"].(string)
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 2 {
|
||||
t.Fatalf("objective[%d] %s: key_results count = %d, want 2", i, objID, len(krs))
|
||||
}
|
||||
// Verify KR IDs are distinct (from different pages)
|
||||
krIDs := make(map[string]bool)
|
||||
for _, krRaw := range krs {
|
||||
kr, _ := krRaw.(map[string]interface{})
|
||||
krID, _ := kr["id"].(string)
|
||||
krIDs[krID] = true
|
||||
}
|
||||
if len(krIDs) != 2 {
|
||||
t.Fatalf("objective %s: expected 2 distinct KR IDs, got %v", objID, krIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/400/objectives",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "400"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
189
shortcuts/okr/okr_cycle_list.go
Normal file
189
shortcuts/okr/okr_cycle_list.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
|
||||
// The start is the first moment of the start month; the end is the last moment of the end month.
|
||||
func parseTimeRange(s string) (start, end time.Time, err error) {
|
||||
parts := strings.SplitN(s, "--", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
|
||||
}
|
||||
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
|
||||
}
|
||||
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
|
||||
}
|
||||
// end is the last moment of the end month
|
||||
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
|
||||
if start.After(end) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// cycleOverlaps checks whether a cycle's [startMs, endMs] overlaps with [rangeStart, rangeEnd].
|
||||
func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd 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)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
Description: "List okr cycles of a certain user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.period:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
|
||||
}
|
||||
userID := runtime.Str("user-id")
|
||||
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := runtime.Str("time-range")
|
||||
if tr != "" {
|
||||
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseTimeRange(tr); err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
timeRange := runtime.Str("time-range")
|
||||
|
||||
// Parse time range for filtering
|
||||
var rangeStart, rangeEnd time.Time
|
||||
var hasRange bool
|
||||
if timeRange != "" {
|
||||
var err error
|
||||
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id", userID)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
for i := range allCycles {
|
||||
if !hasRange || cycleOverlaps(&allCycles[i], rangeStart, rangeEnd) {
|
||||
filtered = append(filtered, allCycles[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
respCycles := make([]*RespCycle, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleListTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-list-" + suffix,
|
||||
AppSecret: "secret-okr-list-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleListShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRListCycles.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleListValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "invalid_type",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --user-id-type")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--user-id-type must be one of") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-\t123",
|
||||
"--user-id-type", "open_id",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --user-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "open_id",
|
||||
"--time-range", "2025-01\t--2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --time-range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidTimeRangeFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01-2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --time-range format")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-06--2025-01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for start after end in --time-range")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidWithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_AllUserIDTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, idType := range []string{"open_id", "union_id", "user_id"} {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "test-id",
|
||||
"--user-id-type", idType,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user-id-type=%q: unexpected error: %v", idType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleListDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-456",
|
||||
"--user-id-type", "open_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "ou-456") {
|
||||
t.Fatalf("dry-run output should contain user-id ou-456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Return two cycles: one inside the range, one outside
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-in-range",
|
||||
"start_time": "1735689600000", // 2025-01-01
|
||||
"end_time": "1738368000000", // 2025-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-out-range",
|
||||
"start_time": "1704067200000", // 2024-01-01
|
||||
"end_time": "1706745600000", // 2024-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1 (only cycle-in-range should pass filter)", len(cycles))
|
||||
}
|
||||
cycle, _ := cycles[0].(map[string]interface{})
|
||||
if cycle["id"] != "cycle-in-range" {
|
||||
t.Fatalf("cycle id = %v, want cycle-in-range", cycle["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// First page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1738368000000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
361
shortcuts/okr/okr_openapi.go
Normal file
361
shortcuts/okr/okr_openapi.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CycleStatus 周期状态
|
||||
type CycleStatus int32
|
||||
|
||||
const (
|
||||
CycleStatusDefault CycleStatus = 0
|
||||
CycleStatusNormal CycleStatus = 1
|
||||
CycleStatusInvalid CycleStatus = 2
|
||||
CycleStatusHidden CycleStatus = 3
|
||||
)
|
||||
|
||||
func (t CycleStatus) Ptr() *CycleStatus { return &t }
|
||||
|
||||
// StatusCalculateType 状态计算类型
|
||||
type StatusCalculateType int32
|
||||
|
||||
const (
|
||||
StatusCalculateTypeManualUpdate StatusCalculateType = 0
|
||||
StatusCalculateTypeAutomaticallyUpdatesBasedOnProgressAndCurrentTime StatusCalculateType = 1
|
||||
StatusCalculateTypeStatusUpdatesBasedOnTheHighestRiskKeyResults StatusCalculateType = 2
|
||||
)
|
||||
|
||||
// BlockElementType 块元素类型
|
||||
type BlockElementType string
|
||||
|
||||
const (
|
||||
BlockElementTypeGallery BlockElementType = "gallery"
|
||||
BlockElementTypeParagraph BlockElementType = "paragraph"
|
||||
)
|
||||
|
||||
func (t BlockElementType) Ptr() *BlockElementType { return &t }
|
||||
|
||||
// CategoryName 分类名称
|
||||
type CategoryName struct {
|
||||
Zh *string `json:"zh,omitempty"`
|
||||
En *string `json:"en,omitempty"`
|
||||
Ja *string `json:"ja,omitempty"`
|
||||
}
|
||||
|
||||
// ListType 列表类型
|
||||
type ListType string
|
||||
|
||||
const (
|
||||
ListTypeBullet ListType = "bullet"
|
||||
ListTypeCheckBox ListType = "checkBox"
|
||||
ListTypeCheckedBox ListType = "checkedBox"
|
||||
ListTypeIndent ListType = "indent"
|
||||
ListTypeNumber ListType = "number"
|
||||
)
|
||||
|
||||
// OwnerType 所有者类型
|
||||
type OwnerType string
|
||||
|
||||
const (
|
||||
OwnerTypeDepartment OwnerType = "department"
|
||||
OwnerTypeUser OwnerType = "user"
|
||||
)
|
||||
|
||||
// ParagraphElementType 段落元素类型
|
||||
type ParagraphElementType string
|
||||
|
||||
const (
|
||||
ParagraphElementTypeDocsLink ParagraphElementType = "docsLink"
|
||||
ParagraphElementTypeMention ParagraphElementType = "mention"
|
||||
ParagraphElementTypeTextRun ParagraphElementType = "textRun"
|
||||
)
|
||||
|
||||
func (t ParagraphElementType) Ptr() *ParagraphElementType { return &t }
|
||||
|
||||
// ContentBlock 内容块
|
||||
type ContentBlock struct {
|
||||
Blocks []ContentBlockElement `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// ContentBlockElement 内容块元素
|
||||
type ContentBlockElement struct {
|
||||
BlockElementType *BlockElementType `json:"block_element_type,omitempty"`
|
||||
Paragraph *ContentParagraph `json:"paragraph,omitempty"`
|
||||
Gallery *ContentGallery `json:"gallery,omitempty"`
|
||||
}
|
||||
|
||||
// ContentColor 颜色
|
||||
type ContentColor struct {
|
||||
Red *int32 `json:"red,omitempty"`
|
||||
Green *int32 `json:"green,omitempty"`
|
||||
Blue *int32 `json:"blue,omitempty"`
|
||||
Alpha *float64 `json:"alpha,omitempty"`
|
||||
}
|
||||
|
||||
// ContentDocsLink 文档链接
|
||||
type ContentDocsLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ContentGallery 图库
|
||||
type ContentGallery struct {
|
||||
Images []ContentImageItem `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ContentImageItem 图片项
|
||||
type ContentImageItem struct {
|
||||
FileToken *string `json:"file_token,omitempty"`
|
||||
Src *string `json:"src,omitempty"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// ContentLink 链接
|
||||
type ContentLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ContentList 列表
|
||||
type ContentList struct {
|
||||
ListType *ListType `json:"list_type,omitempty"`
|
||||
IndentLevel *int32 `json:"indent_level,omitempty"`
|
||||
Number *int32 `json:"number,omitempty"`
|
||||
}
|
||||
|
||||
// ContentMention 提及
|
||||
type ContentMention struct {
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraph 段落
|
||||
type ContentParagraph struct {
|
||||
Style *ContentParagraphStyle `json:"style,omitempty"`
|
||||
Elements []ContentParagraphElement `json:"elements,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphElement 段落元素
|
||||
type ContentParagraphElement struct {
|
||||
ParagraphElementType *ParagraphElementType `json:"paragraph_element_type,omitempty"`
|
||||
TextRun *ContentTextRun `json:"text_run,omitempty"`
|
||||
DocsLink *ContentDocsLink `json:"docs_link,omitempty"`
|
||||
Mention *ContentMention `json:"mention,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphStyle 段落样式
|
||||
type ContentParagraphStyle struct {
|
||||
List *ContentList `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextRun 文本块
|
||||
type ContentTextRun struct {
|
||||
Text *string `json:"text,omitempty"`
|
||||
Style *ContentTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextStyle 文本样式
|
||||
type ContentTextStyle struct {
|
||||
Bold *bool `json:"bold,omitempty"`
|
||||
StrikeThrough *bool `json:"strike_through,omitempty"`
|
||||
BackColor *ContentColor `json:"back_color,omitempty"`
|
||||
TextColor *ContentColor `json:"text_color,omitempty"`
|
||||
Link *ContentLink `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// Cycle 周期
|
||||
type Cycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner Owner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *CycleStatus `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// KeyResult 关键结果
|
||||
type KeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// Objective 目标
|
||||
type Objective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
// Owner OKR 所有者
|
||||
type Owner struct {
|
||||
OwnerType OwnerType `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ToString CycleStatus to string
|
||||
func (t CycleStatus) ToString() string {
|
||||
switch t {
|
||||
case CycleStatusDefault:
|
||||
return "default"
|
||||
case CycleStatusNormal:
|
||||
return "normal"
|
||||
case CycleStatusInvalid:
|
||||
return "invalid"
|
||||
case CycleStatusHidden:
|
||||
return "hidden"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimestamp 格式化毫秒级时间戳为 DateTime 格式
|
||||
func formatTimestamp(ts string) string {
|
||||
if ts == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
t := time.UnixMilli(millis)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// ToResp converts Cycle to RespCycle
|
||||
func (c *Cycle) ToResp() *RespCycle {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
resp.CycleStatus = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToResp converts KeyResult to RespKeyResult
|
||||
func (k *KeyResult) ToResp() *RespKeyResult {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResult{
|
||||
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
|
||||
}
|
||||
// Serialize ContentBlock to JSON string (only if Content is not nil and has blocks)
|
||||
if k.Content != nil && len(k.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(k.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Objective to RespObjective
|
||||
func (o *Objective) ToResp() *RespObjective {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjective{
|
||||
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
|
||||
}
|
||||
// Serialize Content to JSON string
|
||||
if o.Content != nil && len(o.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
// Serialize Notes to JSON string
|
||||
if o.Notes != nil && len(o.Notes.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Notes); err == nil {
|
||||
s := string(bytes)
|
||||
result.Notes = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Owner to RespOwner
|
||||
func (o *Owner) ToResp() *RespOwner {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &RespOwner{
|
||||
OwnerType: string(o.OwnerType),
|
||||
UserID: o.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
// ptrStr dereferences a string pointer, returning "" for nil.
|
||||
func ptrStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// ptrFloat64 dereferences a float64 pointer, returning 0 for nil.
|
||||
func ptrFloat64(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
142
shortcuts/okr/okr_openapi_test.go
Normal file
142
shortcuts/okr/okr_openapi_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestFormatTimestamp(t *testing.T) {
|
||||
convey.Convey("formatTimestamp", t, func() {
|
||||
convey.Convey("empty string returns empty", func() {
|
||||
result := formatTimestamp("")
|
||||
convey.So(result, convey.ShouldEqual, "")
|
||||
})
|
||||
|
||||
convey.Convey("valid timestamp formats correctly", func() {
|
||||
result := formatTimestamp("1735689600000")
|
||||
// 不检查具体的时分秒,因为时区不同结果会不同
|
||||
convey.So(result, convey.ShouldStartWith, "2025-01-01")
|
||||
})
|
||||
|
||||
convey.Convey("invalid timestamp returns original", func() {
|
||||
result := formatTimestamp("not-a-number")
|
||||
convey.So(result, convey.ShouldEqual, "not-a-number")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToRespMethods(t *testing.T) {
|
||||
convey.Convey("ToResp methods handle nil", t, func() {
|
||||
convey.So((*Cycle)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil)
|
||||
})
|
||||
|
||||
convey.Convey("ToResp methods work with valid objects", t, func() {
|
||||
convey.Convey("Cycle", func() {
|
||||
cycle := &Cycle{
|
||||
ID: "cycle-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
TenantCycleID: "tenant-cycle-id",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
StartTime: "1735689600000",
|
||||
EndTime: "1751318400000",
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
Score: float64Ptr(0.75),
|
||||
}
|
||||
resp := cycle.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
obj := &Objective{
|
||||
ID: "obj-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
CycleID: "cycle-id",
|
||||
Position: int32Ptr(1),
|
||||
Score: float64Ptr(0.8),
|
||||
Weight: float64Ptr(1.0),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test objective"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resp := obj.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.8)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("KeyResult", func() {
|
||||
kr := &KeyResult{
|
||||
ID: "kr-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
ObjectiveID: "obj-id",
|
||||
Position: int32Ptr(1),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test KR"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Score: float64Ptr(0.9),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
}
|
||||
resp := kr.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "kr-id")
|
||||
convey.So(resp.ObjectiveID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.9)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to the given string value.
|
||||
func strPtr(v string) *string { return &v }
|
||||
|
||||
// int32Ptr returns a pointer to the given int32 value.
|
||||
func int32Ptr(v int32) *int32 { return &v }
|
||||
|
||||
// float64Ptr returns a pointer to the given float64 value.
|
||||
func float64Ptr(v float64) *float64 { return &v }
|
||||
16
shortcuts/okr/shortcuts.go
Normal file
16
shortcuts/okr/shortcuts.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shortcuts returns all okr shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
OKRListCycles,
|
||||
OKRCycleDetail,
|
||||
}
|
||||
}
|
||||
17
shortcuts/okr/shortcuts_test.go
Normal file
17
shortcuts/okr/shortcuts_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -45,6 +46,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
|
||||
### CRITICAL — 首次使用任何命令前先查 `-h`
|
||||
@@ -104,15 +104,17 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
|
||||
### 命令选择:先判断邮件类型,再决定草稿还是发送
|
||||
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 |
|
||||
|----------|-----------------|---------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` |
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 | 定时发送 |
|
||||
|----------|-----------------|---------|----------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` | `+send --confirm-send --send-time <unix_timestamp>` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` | `+reply --confirm-send --send-time <unix_timestamp>` 或 `+reply-all --confirm-send --send-time <unix_timestamp>` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
- **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明)
|
||||
- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。
|
||||
|
||||
### 使用公共邮箱或别名(send_as)发信
|
||||
|
||||
@@ -151,7 +153,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \
|
||||
|
||||
### 发送后确认投递状态
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -159,6 +161,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
|
||||
|
||||
**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
### 撤回邮件
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
|
||||
29
skill-template/domains/wiki.md
Normal file
29
skill-template/domains/wiki.md
Normal file
@@ -0,0 +1,29 @@
|
||||
> **成员管理硬限制:**
|
||||
> - 如果目标是“部门”,先判断身份,再决定是否继续。
|
||||
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
|
||||
> - 遇到“部门 + --as bot”时,禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
|
||||
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID,再执行 `wiki members create`。
|
||||
|
||||
## 成员添加流程
|
||||
|
||||
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
|
||||
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`。
|
||||
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`。
|
||||
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`。
|
||||
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut,需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`。
|
||||
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`。
|
||||
|
||||
## 目标语义约束
|
||||
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
@@ -250,11 +250,31 @@ metadata:
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
|
||||
### 3.5 执行身份与人员字段
|
||||
### 3.5 身份选择与权限降级策略
|
||||
|
||||
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份操作其有权访问的 Base。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain base
|
||||
```
|
||||
|
||||
- **`--as bot`(降级)**:仅当 user 身份权限不足、且 bot 身份确实拥有目标 Base 的访问权限时,才降级使用。bot 看不到用户私有资源,行为以应用身份执行。
|
||||
|
||||
**执行规则**:
|
||||
|
||||
1. 所有操作默认先用 `--as user`。
|
||||
2. 若 user 身份返回权限错误,先判断是否为**不可重试错误码**(如 `91403`)。若是,**立即停止**,不做任何重试或降级,直接按 `lark-shared` 权限不足处理流程引导用户解决。
|
||||
3. 非不可重试错误码时,检查错误响应中是否包含 `permission_violations` / `hint` 等提权引导信息:
|
||||
- **有提权引导**:按 `lark-shared` 权限不足处理流程,先引导用户完成 user 身份提权(`auth login --scope`);确认提权成功后,以 `--as user` 重试。
|
||||
- **无提权引导**(如资源级无访问权限、非 scope 不足):切换到 `--as bot` 重试**一次**。
|
||||
4. 若 bot 身份仍然返回权限错误,**立即停止重试**,根据错误响应按 `lark-shared` 流程引导用户解决(引导去开发者后台开通 scope 或确认资源访问权限)。
|
||||
5. 只有在用户明确要求"用应用身份 / bot 身份操作",才跳过 user 直接使用 `--as bot`。
|
||||
|
||||
**补充说明**:
|
||||
|
||||
- 人员字段 / 用户字段:注意 `user_id_type` 与执行身份(user / bot)差异。
|
||||
- bot 身份:bot 看不到用户私有资源;行为以应用身份执行。
|
||||
- user 身份:依赖用户授权和 scope;更适合操作用户资源。
|
||||
|
||||
## 4. 执行规则
|
||||
|
||||
@@ -296,7 +316,7 @@ metadata:
|
||||
- 删除记录 / 字段 / 表时,如果用户已经明确说要删除,且目标明确,`+record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。
|
||||
- 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。
|
||||
- `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。
|
||||
- 若 Base 由 bot 身份创建且当前 CLI 存在可用 user 身份,优先继续补授当前 user 为 `full_access`;owner 转移必须单独确认,禁止擅自执行。
|
||||
- 若 Base 由 bot 身份创建或复制,shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`;agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。
|
||||
|
||||
## 5. 常见错误与恢复
|
||||
|
||||
@@ -313,3 +333,4 @@ metadata:
|
||||
| 系统字段 / 公式字段写入失败 | 只读字段被当成可写字段 | 改为写存储字段,计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
| `1254104` | 批量超 200 条 | 分批调用 |
|
||||
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
| `91403` | 无权限访问该 Base | **不要重试**。按 `lark-shared` 权限不足处理流程引导用户解决权限问题 |
|
||||
|
||||
@@ -47,19 +47,14 @@ POST /open-apis/base/v3/bases/:base_token/copy
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Base 是**以应用身份(bot)复制**出来的,agent 在复制成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。
|
||||
> 推荐流程:
|
||||
> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id`
|
||||
> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限
|
||||
> 如果 Base 是**以应用身份(bot)复制**出来的,shortcut 会在复制成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。
|
||||
>
|
||||
> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。
|
||||
> `permission_grant.status` 语义如下:
|
||||
> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限
|
||||
> - `skipped`:Base 已复制成功,但没有可授权的当前 CLI 用户,或复制结果缺少可授权 token
|
||||
> - `failed`:Base 已复制成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果:
|
||||
> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限
|
||||
> - 如果本地没有可用的 user 身份:明确说明因此未完成授权
|
||||
> - 如果授权失败:明确说明 Base 已复制成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。
|
||||
> 回复复制结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。
|
||||
>
|
||||
> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
|
||||
@@ -42,19 +42,14 @@ POST /open-apis/base/v3/bases
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Base 是**以应用身份(bot)创建**的,agent 在创建成功后应**默认继续使用 bot 身份**,为当前可用的 user 身份添加该 Base 的 `full_access`(管理员)权限。
|
||||
> 推荐流程:
|
||||
> 1. 先用 `lark-cli contact +get-user` 获取当前用户信息,并从返回结果中读取该用户的 `open_id`
|
||||
> 2. 再切回 bot 身份,使用这个 `open_id` 给该用户授权该 Base 的 `full_access`(管理员)权限
|
||||
> 如果 Base 是**以应用身份(bot)创建**的,shortcut 会在创建成功后自动尝试为当前 CLI 用户添加该 Base 的 `full_access`(管理员)权限,并在输出中附带 `permission_grant` 字段。
|
||||
>
|
||||
> 如果 `lark-cli contact +get-user` 无法执行,或者本地没有可用的 user 身份、拿不到当前用户的 `open_id`,则应视为“本地没有可用的 user 身份”,明确说明因此未完成授权。
|
||||
> `permission_grant.status` 语义如下:
|
||||
> - `granted`:当前 CLI 用户已获得该 Base 的管理员权限
|
||||
> - `skipped`:Base 已创建成功,但没有可授权的当前 CLI 用户,或创建结果缺少可授权 token
|
||||
> - `failed`:Base 已创建成功,但自动授权失败;结果中会包含失败原因,用户可稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户授权结果:
|
||||
> - 如果授权成功:直接说明当前 user 已获得该 Base 的管理员权限
|
||||
> - 如果本地没有可用的 user 身份:明确说明因此未完成授权
|
||||
> - 如果授权失败:明确说明 Base 已创建成功,但授权失败,并透出失败原因;同时提示用户可以稍后重试授权,或继续使用应用身份(bot)处理该 Base
|
||||
>
|
||||
> 如果授权未完成,应继续给出后续引导:用户可以稍后重试授权,也可以继续使用应用身份(bot)处理该 Base;如果希望后续改由自己管理,也可将 Base owner 转移给该用户。
|
||||
> 回复创建结果时,除 `base token` 和可访问链接外,还必须明确告知用户 `permission_grant` 的结果。
|
||||
>
|
||||
> **仍然不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
|
||||
@@ -97,31 +97,19 @@ Drive Folder (云空间文件夹)
|
||||
└── file_token (直接使用)
|
||||
```
|
||||
|
||||
## 重要说明:画板编辑
|
||||
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
|
||||
### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token
|
||||
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
|
||||
1. 记录画板的 token
|
||||
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 场景 2:刚创建画板,需要编辑
|
||||
如果用户刚通过 `docs +update` 创建了空白画板,需要编辑时:
|
||||
**步骤 1:按空白画板语法创建**
|
||||
- 在 `--markdown` 中直接传 `<whiteboard type="blank"></whiteboard>`
|
||||
- 需要多个空白画板时,在同一个 `--markdown` 里重复多个 whiteboard 标签
|
||||
**步骤 2:从响应中记录 token**
|
||||
- `docs +update` 成功后,读取响应字段 `data.board_tokens`
|
||||
- `data.board_tokens` 是新建画板的 token 列表,后续编辑直接使用这里的 token
|
||||
**步骤 3:引导编辑**
|
||||
- 记录需要编辑的画板 token
|
||||
- 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
|
||||
### 注意事项
|
||||
- 已有画板内容无法通过 lark-doc 的 `docs +update` 直接编辑
|
||||
- 编辑画板需要使用专门的 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md)
|
||||
## 绘图需求识别与挖掘
|
||||
|
||||
## 文档可视化建议
|
||||
> **💡 在撰写文档时,当需要表达较为复杂的时序、架构层次、逻辑关系、数据流程等内容时,建议使用画板绘制可视化图表以显著提升文档的可阅读性。**
|
||||
>
|
||||
> 请参考 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何绘制画板内容。
|
||||
用户很少主动提"画板"——**默认**使用飞书画板承载图表,命中以下任一信号即触发:
|
||||
- 用户提到图表类型:架构图、流程图、时序图、组织图、路线图、对比图、鱼骨图、飞轮图、思维导图等
|
||||
- 用户表达可视化意图:画一下、梳理关系、画个流程、给我一个图、方便汇报等
|
||||
- 文档主题涉及结构关系、流程走向、时间线、数据对比
|
||||
|
||||
以下场景不加图:用户明确拒绝、合同/法律条款/合规声明等严谨连续文本、原样转录任务。
|
||||
|
||||
> [!CAUTION]
|
||||
> 命中后,**MUST** 先读取 [`references/lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 并**严格按其流程执行**。
|
||||
>
|
||||
> **绝对禁止**用 `whiteboard-cli` 渲染 PNG 后通过 `docs +media-insert` 插入文档——图表必须通过 `lark-cli whiteboard +update` 写入画板 block,这是唯一合法路径。
|
||||
|
||||
## 快速决策
|
||||
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
|
||||
@@ -132,45 +120,6 @@ Drive Folder (云空间文件夹)
|
||||
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
|
||||
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”,**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
|
||||
|
||||
## 画板需求挖掘(主动识别)
|
||||
|
||||
> **用户很少主动提"画板"。创建文档时应主动识别适合可视化的内容,用画板呈现。**
|
||||
|
||||
### 🔴 关键要求(必须遵守)
|
||||
|
||||
**创建空白画板 ≠ 完成任务**。创建空白画板后,**必须继续使用 lark-whiteboard 技能填充实际内容**。
|
||||
|
||||
### 语义与画板类型映射
|
||||
|
||||
创建/编辑文档时,文档主题涉及以下语义,应**主动**创建画板,无需用户指定:
|
||||
|
||||
| 语义 | 画板类型 | 参考指南 |
|
||||
|---------------|-------|---------------------------------------------------------------------------------------------|
|
||||
| 架构/分层/技术方案 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 流程/审批/部署/业务流转 | 流程图 | [lark-whiteboard-cli/scenes/flowchart.md](../lark-whiteboard-cli/scenes/flowchart.md) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 | [lark-whiteboard-cli/scenes/organization.md](../lark-whiteboard-cli/scenes/organization.md) |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 | [lark-whiteboard-cli/scenes/milestone.md](../lark-whiteboard-cli/scenes/milestone.md) |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 | [lark-whiteboard-cli/scenes/fishbone.md](../lark-whiteboard-cli/scenes/fishbone.md) |
|
||||
| 方案对比/技术选型 | 对比图 | [lark-whiteboard-cli/scenes/comparison.md](../lark-whiteboard-cli/scenes/comparison.md) |
|
||||
| 循环/飞轮/闭环 | 飞轮图 | [lark-whiteboard-cli/scenes/flywheel.md](../lark-whiteboard-cli/scenes/flywheel.md) |
|
||||
| 层级占比/能力模型 | 金字塔图 | [lark-whiteboard-cli/scenes/pyramid.md](../lark-whiteboard-cli/scenes/pyramid.md) |
|
||||
| 模块依赖/调用关系 | 架构图 | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 分类梳理/知识体系 | 思维导图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
| 数据分布/占比 | 饼图 | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
|
||||
创建画板前,务必先阅读 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 和 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 这两个 Skill,了解画板的创建流程。
|
||||
|
||||
### 完整执行流程(必须完整执行)
|
||||
|
||||
1. **创建空白画板占位**:创建场景用 `docs +create`、编辑场景用 `docs +update` 插入空白画板
|
||||
2. **获取画板 token**:从 `docs +update` 响应的 `data.board_tokens` 获取画板 token 列表
|
||||
3. **填充画板内容**:切换到 [`lark-whiteboard-cli`](../lark-whiteboard-cli/SKILL.md) 创建画板内容,并填入画板
|
||||
4. **验证完成**:确认所有画板都有实际内容,不是空白
|
||||
|
||||
**不适用**:纯文字记录(日志/备忘)、数据密集型内容(用表格)、用户明确只要文字。
|
||||
|
||||
> ⚠️ **警告**:如果只创建空白画板而不填充内容,任务将被视为未完成!
|
||||
|
||||
## 补充说明
|
||||
`docs +search` 除了搜索文档 / Wiki,也承担“先定位云空间对象,再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
|
||||
|
||||
@@ -186,4 +135,4 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
@@ -57,9 +57,8 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown
|
||||
如果文档中包含空白画板(`<whiteboard type="blank"></whiteboard>`),**必须继续以下步骤**:
|
||||
|
||||
1. 从返回值的 `data.board_tokens` 字段记录所有新建画板的 token
|
||||
2. 立即切换到 [`lark-whiteboard`](../lark-whiteboard/SKILL.md) 技能
|
||||
3. 使用 `whiteboard +update` 命令为每个画板填充实际内容(Mermaid/PlantUML/DSL)
|
||||
4. 确认所有画板都有实际内容后,任务才算完成
|
||||
2. 读取 `../../lark-whiteboard/SKILL.md`,跳至"渲染 & 写入画板"章节,为每个 board_token 生成并写入实际内容
|
||||
3. 确认所有画板都有实际内容后,任务才算完成
|
||||
|
||||
**仅创建空白画板是不够的!** 如果只创建空白画板而不填充内容,任务将被视为未完成。
|
||||
|
||||
@@ -85,7 +84,7 @@ lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown
|
||||
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
|
||||
- **图文交融**:流程、架构或草图需要可视化时,优先使用图片、表格或空白画板
|
||||
- **克制留白**:Callout 不过度、加粗只强调核心词
|
||||
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,主动插入空白画板,后续用 lark-whiteboard 填充;但若用户明确要求仅文本或内容更适合表格,则不插入。详见 [画板需求挖掘](../SKILL.md#画板需求挖掘主动识别)
|
||||
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,**必须**在 markdown 对应章节的文字内容之后插入 `<whiteboard type="blank"></whiteboard>` 占位,每个图表对应一个标签。**禁止**用 `whiteboard-cli` 渲染的 PNG/SVG 图片替代画板。创建完成后从返回值 `data.board_tokens` 取 token,读取 `../../lark-whiteboard/SKILL.md` 的"渲染 & 写入画板"章节为每个 token 写入图表内容。例:文档含"系统整体架构""分层架构""部署架构"各需插入一个画板,"类图"也需插入一个画板(走 Mermaid 路由)。
|
||||
|
||||
当用户有明确的样式、风格需求时,应当以用户的需求为准!
|
||||
|
||||
@@ -450,7 +449,7 @@ lark-cli docs +create --title "空白画板示例" --markdown '<whiteboard type=
|
||||
**重要说明**:
|
||||
- 创建空白画板时,直接使用 `<whiteboard type="blank"></whiteboard>`
|
||||
- 读取时只能获取 token,可通过 media-download 查看内容,无法直接读出画板内部内容
|
||||
- 画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
|
||||
- 画板编辑:详见 [../../lark-whiteboard/SKILL.md](../../lark-whiteboard/SKILL.md)
|
||||
|
||||
### 多维表格(Base)
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
# docs +whiteboard-update(更新飞书画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 shortcut 仅为兼容历史调用保留,具体使用方式请参考 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)
|
||||
66
skills/lark-doc/references/lark-doc-whiteboard.md
Normal file
66
skills/lark-doc/references/lark-doc-whiteboard.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# lark-doc 画板处理指南
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
## 两个 Skill 的职责边界
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|------|------|------|
|
||||
| `lark-doc` | 文档内容读取/更新、插入空白画板占位、获取 board_token | 不能直接编辑画板内容;`docs +update` 的画板能力仅限插入空白占位 |
|
||||
| `lark-whiteboard` | 查询/导出画板(+query);图表内容生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入画板(+update) | 图表内容生成由此 skill 完整执行,不依赖外部调度 |
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
### 步骤 1:判断场景
|
||||
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入新画板 | 继续步骤 2 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3 |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
### 步骤 2:在文档中创建空白画板
|
||||
|
||||
- 创建场景:`docs +create`;编辑场景:`docs +update`
|
||||
- markdown 中使用 `<whiteboard type="blank"></whiteboard>`(不要转义)
|
||||
- 多个画板时,在相应的地方插入各自的 whiteboard 标签
|
||||
- 从响应的 `data.board_tokens` 中读取 token 列表
|
||||
|
||||
### 步骤 3:生成并写入画板内容
|
||||
|
||||
读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),跳至"渲染 & 写入画板"章节,按其完整流程为每个 board_token 生成并写入图表内容。
|
||||
|
||||
多个画板时依次处理,每个画板完成后再处理下一个。
|
||||
|
||||
### 步骤 4:完成校验
|
||||
|
||||
- 确认每个 token 对应的画板都已填充真实内容
|
||||
- 不保留空白占位画板;只有空白画板而无内容视为任务未完成
|
||||
|
||||
---
|
||||
|
||||
## 语义与画板类型映射
|
||||
|
||||
| 语义 | 画板类型 |
|
||||
|------|------|
|
||||
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图 |
|
||||
| 流程/审批/部署/业务流转/状态机 | 流程图 |
|
||||
| 跨角色流程/跨系统交互/端到端链路 | 泳道图 |
|
||||
| 组织/层级/汇报关系 | 组织架构图 |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 |
|
||||
| 方案对比/技术选型/功能矩阵 | 对比图 |
|
||||
| 循环/飞轮/闭环/增长链路 | 飞轮图 |
|
||||
| 层级占比/能力模型/需求层次 | 金字塔图 |
|
||||
| 矩形树图/层级面积占比 | 树状图 |
|
||||
| 转化漏斗/销售漏斗 | 漏斗图 |
|
||||
| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid |
|
||||
| 数据分布/占比/饼图 | Mermaid |
|
||||
| 柱状图/条形图/数据对比 | 柱状图 |
|
||||
| 折线图/趋势图/时序数据 | 折线图 |
|
||||
|
||||
---
|
||||
|
||||
## 关联参考
|
||||
|
||||
- 画板查询/创作/修改/渲染写入:[`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
给文档添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--selection-with-ellipsis` 或 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论),也支持传最终可解析为 doc/docx 的 wiki URL。
|
||||
给文档或电子表格添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--selection-with-ellipsis` 或 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、sheet URL,也支持传最终可解析为 doc/docx/sheet 的 wiki URL。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -42,6 +42,28 @@ lark-cli drive +add-comment \
|
||||
--selection-with-ellipsis "流程" \
|
||||
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
|
||||
|
||||
# 给电子表格单元格添加评论(--block-id 格式为 <sheetId>!<cell>)
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/sheets/<SHEET_TOKEN>" \
|
||||
--block-id "<SHEET_ID>!D6" \
|
||||
--content '[{"type":"text","text":"请检查此单元格数据"}]'
|
||||
|
||||
# wiki 链接指向的 sheet 也支持
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
|
||||
--block-id "<SHEET_ID>!A1" \
|
||||
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 确认"}]'
|
||||
|
||||
# 传裸 token 时需要 --type 指定文档类型
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<SHEET_TOKEN>" --type sheet \
|
||||
--block-id "<SHEET_ID>!D6" \
|
||||
--content '[{"type":"text","text":"请检查"}]'
|
||||
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<DOCX_TOKEN>" --type docx \
|
||||
--content '[{"type":"text","text":"全文评论"}]'
|
||||
|
||||
# 已知 block_id 时可跳过 MCP 直接创建局部评论
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<DOCX_TOKEN>" \
|
||||
@@ -67,14 +89,16 @@ lark-cli drive +add-comment \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc` | 是 | 文档 URL / token,或可解析到 `doc`/`docx` 的 wiki URL。原始 token 默认按 `docx` 处理;旧版 `doc` 请传 URL 或 wiki 链接 |
|
||||
| `--doc` | 是 | 文档 URL / token、sheet URL,或可解析到 `doc`/`docx`/`sheet` 的 wiki URL |
|
||||
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`sheet`。URL 输入时自动识别,无需传 |
|
||||
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论 |
|
||||
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥 |
|
||||
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥 |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论(不适用于 sheet) |
|
||||
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥(不适用于 sheet) |
|
||||
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6`) |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`)。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。
|
||||
- **无需预先获取文档内容**:使用 `--selection-with-ellipsis` 时,shortcut 内部会自动调用 `locate-doc` 定位目标文本,不需要先调用 `docs +fetch` 获取文档。
|
||||
- 未传 `--selection-with-ellipsis` / `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。
|
||||
- 全文评论支持 `docx`、旧版 `doc` URL,以及最终可解析为 `doc`/`docx` 的 wiki URL。
|
||||
|
||||
@@ -56,7 +56,7 @@ metadata:
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
|
||||
### CRITICAL — 首次使用任何命令前先查 `-h`
|
||||
@@ -118,15 +118,17 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
|
||||
|
||||
### 命令选择:先判断邮件类型,再决定草稿还是发送
|
||||
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 |
|
||||
|----------|-----------------|---------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` |
|
||||
| 邮件类型 | 存草稿(不发送) | 直接发送 | 定时发送 |
|
||||
|----------|-----------------|---------|----------|
|
||||
| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` | `+send --confirm-send --send-time <unix_timestamp>` |
|
||||
| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` | `+reply --confirm-send --send-time <unix_timestamp>` 或 `+reply-all --confirm-send --send-time <unix_timestamp>` |
|
||||
| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time <unix_timestamp>` |
|
||||
|
||||
- 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`**
|
||||
- **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`**
|
||||
- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明)
|
||||
- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明)
|
||||
|
||||
> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。
|
||||
|
||||
### 使用公共邮箱或别名(send_as)发信
|
||||
|
||||
@@ -165,7 +167,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \
|
||||
|
||||
### 发送后确认投递状态
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -173,6 +175,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
|
||||
|
||||
**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
### 撤回邮件
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
@@ -335,6 +345,7 @@ lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
|
||||
### user_mailbox.drafts
|
||||
|
||||
- `cancel_scheduled_send` — 取消定时发送
|
||||
- `create` — 创建草稿
|
||||
- `delete` — 删除指定邮箱账户下的单份邮件草稿。注意:对于草稿状态的邮件,只能使用本接口删除,禁止使用 trash_message;被删除的草稿数据无法恢复,请谨慎使用。
|
||||
- `get` — 获取草稿详情
|
||||
@@ -420,6 +431,7 @@ lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
| `user_mailboxes.accessible_mailboxes` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.profile` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.search` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.drafts.cancel_scheduled_send` | `mail:user_mailbox.message:send` |
|
||||
| `user_mailbox.drafts.create` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.drafts.delete` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.drafts.get` | `mail:user_mailbox.message:readonly` |
|
||||
|
||||
@@ -52,6 +52,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|
||||
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '测试' --dry-run
|
||||
| `--set-to <emails>` | 否 | 用此处提供的地址替换整个 To 收件人列表 |
|
||||
| `--set-cc <emails>` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 |
|
||||
| `--set-bcc <emails>` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 |
|
||||
| `--set-priority <level>` | 否 | 设置邮件优先级:`high`、`normal`、`low`。设为 `normal` 会清除已有优先级 |
|
||||
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
|
||||
| `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 |
|
||||
| `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(含每个附件的 `part_id`、`cid`、`filename`)和 `inline_summary` 的草稿投影 |
|
||||
|
||||
@@ -67,7 +67,9 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -114,6 +116,25 @@ lark-cli mail +forward --message-id <邮件ID> --to bob@example.com --body '<p>F
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点转发给 Bob"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建转发草稿
|
||||
lark-cli mail +forward --message-id <邮件ID> --to bob@example.com --body '<p>FYI,请查收。</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "转发草稿已创建:收件人 bob@example.com,定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不转发了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 转发整个会话
|
||||
|
||||
`+forward` 操作的是单封邮件(`--message-id`),但转发整个会话时应 forward **会话中最后一条消息**,因为邮件客户端会将完整的回复链嵌套在最新一条中。典型流程:
|
||||
@@ -139,7 +160,9 @@ lark-cli mail +forward --message-id <最后一条的message_id> --to recipient@e
|
||||
|
||||
转发发送成功后:
|
||||
|
||||
**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态:
|
||||
**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须)
|
||||
|
||||
用返回的 `message_id` 查询投递状态:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -147,6 +170,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。
|
||||
|
||||
**1b. 定时发送(指定了 `--send-time`)**
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。
|
||||
|
||||
如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -71,7 +71,9 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -118,6 +120,25 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '<p>已确认。</p>'
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点回复全部说已确认"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建回复全部草稿
|
||||
lark-cli mail +reply-all --message-id <邮件ID> --body '<p>已确认。</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "回复全部草稿已创建:收件人 alice@, bob@, carol@,内容「已确认。」定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不回复了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- 自动收件人规则:原发件人优先进入 To,原 To/Cc 进入 Cc。
|
||||
@@ -129,7 +150,9 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_
|
||||
|
||||
回复发送成功后:
|
||||
|
||||
**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态:
|
||||
**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须)
|
||||
|
||||
用返回的 `message_id` 查询投递状态:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -137,6 +160,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。
|
||||
|
||||
**1b. 定时发送(指定了 `--send-time`)**
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。
|
||||
|
||||
如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -74,7 +74,9 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -121,6 +123,25 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>已处理,谢谢。</p
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点回复这封邮件说已处理"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建回复草稿
|
||||
lark-cli mail +reply --message-id <邮件ID> --body '<p>已处理,谢谢。</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "回复草稿已创建:回复给 alice@example.com,内容「已处理,谢谢。」定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不回复了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 实现说明
|
||||
|
||||
### 会话维护
|
||||
@@ -144,7 +165,9 @@ References: <原邮件references + smtp_message_id>
|
||||
|
||||
回复发送成功后:
|
||||
|
||||
**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态:
|
||||
**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须)
|
||||
|
||||
用返回的 `message_id` 查询投递状态:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}'
|
||||
@@ -152,6 +175,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。
|
||||
|
||||
**1b. 定时发送(指定了 `--send-time`)**
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。
|
||||
|
||||
如需取消定时发送:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -71,7 +71,9 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 |
|
||||
| `--send-time <timestamp>` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
## 返回值
|
||||
@@ -120,8 +122,29 @@ lark-cli mail +send --to alice@example.com --subject '收到' --body '<p>已收
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
### 场景 3:用户说"下午 3 点给 Alice 发一封周报"(定时发送)
|
||||
```bash
|
||||
# Step 1: 创建草稿(定时发送也走草稿流程)
|
||||
lark-cli mail +send --to alice@example.com --subject '周报' --body '<p>本周进展如下...</p>'
|
||||
# → 返回 draft_id
|
||||
|
||||
# Step 2: 向用户确认 "邮件草稿已创建:收件人 alice@example.com,主题「周报」,定时 <目标时间> 发送。确认吗?"
|
||||
|
||||
# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟)
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'
|
||||
```
|
||||
|
||||
### 场景 4:用户说"等等,先不发那封邮件了"(取消定时发送)
|
||||
```bash
|
||||
# 取消定时发送(取消后邮件变回草稿)
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。
|
||||
|
||||
## 发送后跟进
|
||||
|
||||
### 立即发送(无 `--send-time`)
|
||||
|
||||
邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` 查询投递状态:
|
||||
|
||||
```bash
|
||||
@@ -130,6 +153,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
|
||||
状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告各收件人投递结果,异常状态需重点提示。
|
||||
|
||||
### 定时发送(指定了 `--send-time`)
|
||||
|
||||
定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询投递状态。
|
||||
|
||||
如需取消定时发送,可在预定时间前调用取消接口:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
```
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
## 实现说明
|
||||
|
||||
- 使用 EML 构建器生成完整 MIME 邮件并 base64url 编码后发送。
|
||||
|
||||
127
skills/lark-okr/SKILL.md
Normal file
127
skills/lark-okr/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: lark-okr
|
||||
version: 1.0.0
|
||||
description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
cliHelp: "lark-cli okr --help"
|
||||
---
|
||||
|
||||
# okr (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|--------------------------------------------------------|--------------------------|
|
||||
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
|
||||
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
|
||||
|
||||
## 格式说明
|
||||
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Notes 字段使用的富文本格式说明
|
||||
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
|
||||
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema okr.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli okr <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,**必须**先运行 `schema` 查看 `--data` / `--params` 参数结构,**不要**猜测字段格式!
|
||||
|
||||
### alignments
|
||||
|
||||
- `delete` — 删除对齐关系
|
||||
- `get` — 获取对齐关系
|
||||
|
||||
### categories
|
||||
|
||||
- `list` — 批量获取分类
|
||||
|
||||
### cycles
|
||||
|
||||
- `list` — 批量获取用户周期
|
||||
- `objectives_position` — 更新用户周期下全部目标的位置
|
||||
- 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。
|
||||
- `objectives_weight` — 更新用户周期下全部目标的权重
|
||||
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
|
||||
### cycle.objectives
|
||||
|
||||
- `create` — 创建目标
|
||||
- `list` — 批量获取用户周期下的目标
|
||||
|
||||
### indicators
|
||||
|
||||
- `patch` — 更新量化指标
|
||||
|
||||
### key_results
|
||||
|
||||
- `delete` — 删除关键结果
|
||||
- `get` — 获取关键结果
|
||||
- `patch` — 更新关键结果
|
||||
|
||||
### key_result.indicators
|
||||
|
||||
- `list` — 获取关键结果的量化指标
|
||||
|
||||
### objectives
|
||||
|
||||
- `delete` — 删除目标
|
||||
- `get` — 获取目标
|
||||
- `key_results_position` — 更新全部关键结果的位置
|
||||
- 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。
|
||||
- `key_results_weight` — 更新全部关键结果的权重
|
||||
- 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
- `patch` — 更新目标
|
||||
|
||||
### objective.alignments
|
||||
|
||||
- `create` — 创建对齐关系
|
||||
- 对齐不允许对齐自己的目标,且发起对齐的目标和被对齐的目标所在周期时间上必须有重叠,否则会参数校验失败。
|
||||
- `list` — 批量获取目标下的对齐关系
|
||||
|
||||
### objective.indicators
|
||||
|
||||
- `list` — 获取目标的量化指标
|
||||
|
||||
### objective.key_results
|
||||
|
||||
- `create` — 创建关键结果
|
||||
- `list` — 批量获取目标下的关键结果
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|-----------------------------------|-----------------------------|
|
||||
| `alignments.delete` | `okr:okr.content:writeonly` |
|
||||
| `alignments.get` | `okr:okr.content:readonly` |
|
||||
| `categories.list` | `okr:okr.setting:read` |
|
||||
| `cycles.list` | `okr:okr.period:readonly` |
|
||||
| `cycles.objectives_position` | `okr:okr.content:writeonly` |
|
||||
| `cycles.objectives_weight` | `okr:okr.content:writeonly` |
|
||||
| `cycle.objectives.create` | `okr:okr.content:writeonly` |
|
||||
| `cycle.objectives.list` | `okr:okr.content:readonly` |
|
||||
| `indicators.patch` | `okr:okr.content:writeonly` |
|
||||
| `key_results.delete` | `okr:okr.content:writeonly` |
|
||||
| `key_results.get` | `okr:okr.content:readonly` |
|
||||
| `key_results.patch` | `okr:okr.content:writeonly` |
|
||||
| `key_result.indicators.list` | `okr:okr.content:readonly` |
|
||||
| `objectives.delete` | `okr:okr.content:writeonly` |
|
||||
| `objectives.get` | `okr:okr.content:readonly` |
|
||||
| `objectives.key_results_position` | `okr:okr.content:writeonly` |
|
||||
| `objectives.key_results_weight` | `okr:okr.content:writeonly` |
|
||||
| `objectives.patch` | `okr:okr.content:writeonly` |
|
||||
| `objective.alignments.create` | `okr:okr.content:writeonly` |
|
||||
| `objective.alignments.list` | `okr:okr.content:readonly` |
|
||||
| `objective.indicators.list` | `okr:okr.content:readonly` |
|
||||
| `objective.key_results.create` | `okr:okr.content:writeonly` |
|
||||
| `objective.key_results.list` | `okr:okr.content:readonly` |
|
||||
|
||||
313
skills/lark-okr/references/lark-okr-contentblock.md
Normal file
313
skills/lark-okr/references/lark-okr-contentblock.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# OKR ContentBlock 富文本格式
|
||||
|
||||
OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` 富文本格式。本文档描述其结构和使用方式。
|
||||
|
||||
## ContentBlock 结构概览
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0,
|
||||
"number": 1
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "Hello World",
|
||||
"style": {
|
||||
"bold": true,
|
||||
"strike_through": false,
|
||||
"back_color": {
|
||||
"red": 255,
|
||||
"green": 0,
|
||||
"blue": 0,
|
||||
"alpha": 1
|
||||
},
|
||||
"text_color": {
|
||||
"red": 0,
|
||||
"green": 255,
|
||||
"blue": 0,
|
||||
"alpha": 1
|
||||
},
|
||||
"link": {
|
||||
"url": "https://example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "docsLink",
|
||||
"docs_link": {
|
||||
"url": "https://larkoffice.com/docx/xxx",
|
||||
"title": "Lark Document"
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "mention",
|
||||
"mention": {
|
||||
"user_id": "ou_xxx"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "gallery",
|
||||
"gallery": {
|
||||
"images": [
|
||||
{
|
||||
"file_token": "file_xxx",
|
||||
"src": "https://...",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
### ContentBlock
|
||||
|
||||
根级别内容块。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------|-------------------------|---------|
|
||||
| `blocks` | `ContentBlockElement[]` | 内容块元素数组 |
|
||||
|
||||
### ContentBlockElement
|
||||
|
||||
内容块元素,支持段落或图库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------------|--------------------|--------------------------------------------|
|
||||
| `block_element_type` | `BlockElementType` | 块类型:`paragraph` \| `gallery` |
|
||||
| `paragraph` | `ContentParagraph` | 段落内容(当 `block_element_type="paragraph"` 时) |
|
||||
| `gallery` | `ContentGallery` | 图库内容(当 `block_element_type="gallery"` 时) |
|
||||
|
||||
### ContentParagraph
|
||||
|
||||
段落内容。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------|-----------------------------|-------------|
|
||||
| `style` | `ContentParagraphStyle` | 段落样式(列表类型等) |
|
||||
| `elements` | `ContentParagraphElement[]` | 段落内元素数组 |
|
||||
|
||||
### ContentParagraphElement
|
||||
|
||||
段落内元素,支持文本、文档链接、提及。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------------------|------------------------|-------------------------------------------|
|
||||
| `paragraph_element_type` | `ParagraphElementType` | 元素类型:`textRun` \| `docsLink` \| `mention` |
|
||||
| `text_run` | `ContentTextRun` | 文本内容 |
|
||||
| `docs_link` | `ContentDocsLink` | 飞书文档链接 |
|
||||
| `mention` | `ContentMention` | 用户提及 |
|
||||
|
||||
### ContentTextRun
|
||||
|
||||
文本块。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|--------------------|------|
|
||||
| `text` | `string` | 文本内容 |
|
||||
| `style` | `ContentTextStyle` | 文本样式 |
|
||||
|
||||
### ContentTextStyle
|
||||
|
||||
文本样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------------|----------------|-------|
|
||||
| `bold` | `boolean` | 是否粗体 |
|
||||
| `strike_through` | `boolean` | 是否删除线 |
|
||||
| `back_color` | `ContentColor` | 背景颜色 |
|
||||
| `text_color` | `ContentColor` | 文字颜色 |
|
||||
| `link` | `ContentLink` | 链接 |
|
||||
|
||||
### ContentColor
|
||||
|
||||
颜色。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|-----------|--------------|
|
||||
| `red` | `int32` | 红色通道 (0-255) |
|
||||
| `green` | `int32` | 绿色通道 (0-255) |
|
||||
| `blue` | `int32` | 蓝色通道 (0-255) |
|
||||
| `alpha` | `float64` | 透明度 (0-1) |
|
||||
|
||||
### ContentParagraphStyle
|
||||
|
||||
段落样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------|---------------|------|
|
||||
| `list` | `ContentList` | 列表样式 |
|
||||
|
||||
### ContentList
|
||||
|
||||
列表样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------|------------|---------------------------------------------------------------------|
|
||||
| `list_type` | `ListType` | 列表类型:`bullet` \| `number` \| `checkBox` \| `checkedBox` \| `indent` |
|
||||
| `indent_level` | `int32` | 缩进层级 |
|
||||
| `number` | `int32` | 序号(当 `list_type="number"` 时) |
|
||||
|
||||
### ContentGallery
|
||||
|
||||
图库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------|----------------------|-------|
|
||||
| `images` | `ContentImageItem[]` | 图片项数组 |
|
||||
|
||||
### ContentImageItem
|
||||
|
||||
图片项。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|-----------|----------|
|
||||
| `file_token` | `string` | 文件 token |
|
||||
| `src` | `string` | 图片 URL |
|
||||
| `width` | `float64` | 宽度 |
|
||||
| `height` | `float64` | 高度 |
|
||||
|
||||
### ContentDocsLink
|
||||
|
||||
飞书文档链接。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
| `title` | `string` | 链接标题 |
|
||||
|
||||
### ContentMention
|
||||
|
||||
提及。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------|----------|-------|
|
||||
| `user_id` | `string` | 用户 ID |
|
||||
|
||||
### ContentLink
|
||||
|
||||
链接。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:简单文本段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "提升用户满意度"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 2:带格式的文本段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "Q2 目标",
|
||||
"style": {
|
||||
"bold": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": " - 提升产品质量"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 3:带列表的段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "完成功能开发"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "进行用户测试"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
83
skills/lark-okr/references/lark-okr-cycle-detail.md
Normal file
83
skills/lark-okr/references/lark-okr-cycle-detail.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# okr +cycle-detail
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定 OKR 周期下的所有目标及其关键结果。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 列出指定周期的目标和关键结果
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|-------------------------|----|--------|-----------------------------------------|
|
||||
| `--cycle-id <id>` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format <fmt>` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `lark-cli okr +cycle-list` 获取 OKR 周期 ID。
|
||||
2. 执行 `lark-cli okr +cycle-detail --cycle-id "123456"`。
|
||||
3. 报告结果:找到的目标数量、每个目标的 ID、分数、权重及其关键结果。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"cycle_id": "1234567890123456789",
|
||||
"objectives": [
|
||||
{
|
||||
"id": "2345678901234567890",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-15 12:00:00",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"cycle_id": "1234567890123456789",
|
||||
"position": 0,
|
||||
"score": 0.75,
|
||||
"weight": 1.0,
|
||||
"deadline": "2025-06-30 23:59:59",
|
||||
"category_id": "cat_456",
|
||||
"content": "{...}",
|
||||
"notes": "{...}",
|
||||
"key_results": [
|
||||
{
|
||||
"id": "3456789012345678901",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-15 12:00:00",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"objective_id": "2345678901234567890",
|
||||
"position": 0,
|
||||
"score": 0.8,
|
||||
"weight": 0.5,
|
||||
"deadline": "2025-06-30 23:59:59",
|
||||
"content": "{...}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
87
skills/lark-okr/references/lark-okr-cycle-list.md
Normal file
87
skills/lark-okr/references/lark-okr-cycle-list.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# okr +cycle-list
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定用户的 OKR 周期,支持可选的时间范围过滤。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 列出用户的所有周期
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx"
|
||||
|
||||
# 使用特定的用户 ID 类型列出周期
|
||||
lark-cli okr +cycle-list --user-id "xxx" --user-id-type user_id
|
||||
|
||||
# 列出时间范围内的周期(例如 2025-01 到 2025-06)
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --time-range "2025-01--2025-06"
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|-------------------------------|----|-----------|------------------------------------------------------------------|
|
||||
| `--user-id <id>` | 是 | — | OKR 所有者的用户 ID |
|
||||
| `--user-id-type <type>` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--time-range <range>` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format <fmt>` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 获取目标用户的 `open_id`(或其他 ID 类型)。如果用户说"我的 OKR 周期",先通过 `lark-cli contact +get-user` 获取当前用户的
|
||||
ID。
|
||||
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx"`,可选择使用 `--time-range`。
|
||||
3. 报告结果:找到的周期数量、每个周期的 ID、开始/结束时间和状态。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"cycles": [
|
||||
{
|
||||
"id": "1234567890123456789",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-01 00:00:00",
|
||||
"tenant_cycle_id": "789",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"start_time": "2025-01-01 00:00:00",
|
||||
"end_time": "2025-06-30 00:00:00",
|
||||
"cycle_status": "normal",
|
||||
"score": 0
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
在这个周期信息中,这些字段值得关注:
|
||||
- `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情
|
||||
- `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而 “2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。
|
||||
- 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是 “2025 年” 的年度周期
|
||||
- `cycle_status` 为周期状态值,参见下文。
|
||||
|
||||
### 周期状态值
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----------|----------|
|
||||
| `default` | 默认状态 (0) |
|
||||
| `normal` | 生效 (1) |
|
||||
| `invalid` | 失效 (2) |
|
||||
| `hidden` | 隐藏 (3) |
|
||||
|
||||
在 OKR 系统中,default/normal 状态下的周期当前正常生效,invalid 状态下的周期已失效但通常仍然可以填写,hidden 状态下的周期隐藏不可见。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
270
skills/lark-okr/references/lark-okr-entities.md
Normal file
270
skills/lark-okr/references/lark-okr-entities.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# OKR 实体定义
|
||||
|
||||
本文档描述飞书 OKR API (`/open-apis/okr/v2`) 中涉及的核心实体及其字段定义。
|
||||
|
||||
## 实体关系概览
|
||||
|
||||
```
|
||||
Cycle (用户周期)
|
||||
└── Objective (目标)
|
||||
├── KeyResult (关键结果)
|
||||
│ └── Indicator (指标)
|
||||
└── Indicator (指标)
|
||||
|
||||
Alignment (对齐关系): Objective ↔ Objective
|
||||
Category (分类): Objective 的分组标签
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Owner (所有者)
|
||||
|
||||
所有者标识 OKR 实体的归属,目前仅支持用户类型。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------|----------|----|-----------------------------------------------|
|
||||
| `owner_type` | `string` | 是 | 所有者类型,通常为 `"user"`。 |
|
||||
| `user_id` | `string` | 否 | 员工 ID,类型由请求参数 `user_id_type` 决定(默认 `open_id`) |
|
||||
|
||||
---
|
||||
|
||||
## Cycle (用户周期)
|
||||
|
||||
用户周期是 OKR 的顶层容器,代表一个时间段内的所有目标与关键结果。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-------------------|-----------|----|--------------------------------------------|
|
||||
| `id` | `string` | 是 | 用户周期 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间 |
|
||||
| `update_time` | `string` | 是 | 更新时间 |
|
||||
| `tenant_cycle_id` | `string` | 是 | 租户周期 ID(同一周期在不同用户下有不同的用户周期 ID,但租户周期 ID 相同) |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `start_time` | `string` | 是 | 周期开始时间。总是从某月1日开始 |
|
||||
| `end_time` | `string` | 是 | 周期结束时间。到某月最后一日结束 |
|
||||
| `cycle_status` | `integer` | 否 | 周期状态,见下表 |
|
||||
| `score` | `number` | 否 | 周期分数,范围 [0, 1],支持一位小数 |
|
||||
|
||||
### 常用术语
|
||||
|
||||
- **当前周期**: 指周期的 start_time/end_time 所在的时间段与当前时间重叠的周期。如果有多个符合这一标准的周期,通常可以选择周期状态为default/normal的周期,其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。
|
||||
- **所有者**: 绝大多数所有者都是用户,少部分租户启用了“团队OKR”功能,所有者可能是部门。用户身份下,只能编辑所有者为当前用户的
|
||||
OKR。
|
||||
|
||||
### 周期状态 (cycle_status)
|
||||
|
||||
| 值 | 常量名 | 说明 |
|
||||
|---|-----------|-------------|
|
||||
| 0 | `default` | 默认状态 |
|
||||
| 1 | `normal` | 生效中 |
|
||||
| 2 | `invalid` | 已失效(通常仍可填写) |
|
||||
| 3 | `hidden` | 已隐藏(不可见) |
|
||||
|
||||
> **SHORTCUT:** `okr +cycle-list` [lark-okr-cycle-list.md](lark-okr-cycle-list.md) 获取用户的周期列表,可按时间筛选
|
||||
>
|
||||
> **API:** `cycles.list`
|
||||
|
||||
---
|
||||
|
||||
## Objective (目标)
|
||||
|
||||
目标是 OKR 中的 "O",属于某个用户周期,可包含多个关键结果。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---------------|----------------|----|---------------------------------------------------------|
|
||||
| `id` | `string` | 是 | 目标 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `cycle_id` | `string` | 是 | 所属用户周期 ID |
|
||||
| `position` | `integer` | 是 | 排序序号,从 1 开始,范围 [1, 100] |
|
||||
| `content` | `ContentBlock` | 否 | 目标内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `score` | `number` | 否 | 目标分数,范围 [0, 1],支持一位小数 |
|
||||
| `notes` | `ContentBlock` | 否 | 目标备注(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `weight` | `number` | 否 | 目标权重,范围 [0, 1],支持三位小数 |
|
||||
| `deadline` | `string` | 否 | 截止时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `category_id` | `string` | 否 | 所属分类 ID |
|
||||
|
||||
> **SHORTCUT:**
|
||||
> - `okr +cycle-detail` [lark-okr-cycle-detail.md](lark-okr-cycle-detail.md) 获取某个用户周期下的全部目标和关键结果。时间相关的字段会以日期时间格式解析
|
||||
>
|
||||
> **API:**
|
||||
> - `cycle.objectives.list` — 获取周期下的目标列表
|
||||
> - `objectives.get` — 获取单个目标
|
||||
> - `cycle.objectives.create` — 创建目标
|
||||
> - `objectives.delete` — 删除目标
|
||||
> - `cycles.objectives_position` — 更新周期下的目标排序
|
||||
> - `cycles.objectives_weight` — 更新周期下的目标权重
|
||||
|
||||
---
|
||||
|
||||
## KeyResult (关键结果)
|
||||
|
||||
关键结果是 OKR 中的 "KR",属于某个目标,描述目标的可衡量成果。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|----------------|----------------|----|-----------------------------------------------------------|
|
||||
| `id` | `string` | 是 | 关键结果 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 修改时间,毫秒时间戳 |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `objective_id` | `string` | 是 | 所属目标 ID |
|
||||
| `position` | `integer` | 是 | 排序序号,从 1 开始,范围 [1, 100] |
|
||||
| `content` | `ContentBlock` | 否 | 关键结果内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `score` | `number` | 否 | 关键结果分数,范围 [0, 1],支持一位小数 |
|
||||
| `weight` | `number` | 否 | 权重,范围 [0, 1],支持三位小数 |
|
||||
| `deadline` | `string` | 否 | 截止时间,毫秒时间戳 |
|
||||
|
||||
> **API:**
|
||||
> - `objective.key_results.list` — 获取目标下的关键结果列表
|
||||
> - `key_results.get` — 获取单个关键结果
|
||||
> - `key_results.patch` — 更新关键结果
|
||||
> - `key_results.delete` — 删除关键结果
|
||||
> - `objectives.key_results_position` — 更新目标下的关键结果排序
|
||||
> - `objectives.key_results_weight` — 更新目标下的关键结果权重
|
||||
|
||||
---
|
||||
|
||||
## Indicator (指标)
|
||||
|
||||
指标是目标和关键结果的量化度量,可独立挂载在 Objective 或 KeyResult 上。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------------------------|-----------------|----|------------------------------------|
|
||||
| `id` | `string` | 是 | 指标 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳 |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `entity_type` | `integer` | 是 | 所属实体类型:`2`=目标,`3`=关键结果 |
|
||||
| `entity_id` | `string` | 是 | 所属实体 ID |
|
||||
| `indicator_status` | `integer` | 是 | 指标状态,见下表 |
|
||||
| `status_calculate_type` | `integer` | 是 | 状态计算方式,见下表 |
|
||||
| `start_value` | `number` | 否 | 起始值,范围 [-99999999999, 99999999999] |
|
||||
| `target_value` | `number` | 否 | 目标值,范围 [-99999999999, 99999999999] |
|
||||
| `current_value` | `number` | 否 | 当前值,范围 [-99999999999, 99999999999] |
|
||||
| `current_value_calculate_type` | `integer` | 否 | 当前值计算方式,见下表 |
|
||||
| `unit` | `IndicatorUnit` | 否 | 指标单位 |
|
||||
|
||||
### 修改指南
|
||||
|
||||
- **进度值**: 一般指 `current_value`,单位未提及时通常用百分制计算。
|
||||
- 当用户要求量化的更新 OKR 进度时,一般指的就是修改对应 OKR 的 Indicator。
|
||||
- OKR 在未设置量化指标时,Indicator 的内容为空。如果用户未做特别说明,更新进度时可以默认将进度以百分制设置(初始值0,目标值100,unit 参见下文设置为 0/PERCENT)
|
||||
|
||||
### 指标状态 (indicator_status)
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|-----|
|
||||
| -1 | 未定义 |
|
||||
| 0 | 正常 |
|
||||
| 1 | 有风险 |
|
||||
| 2 | 已延期 |
|
||||
|
||||
### 状态计算方式 (status_calculate_type)
|
||||
|
||||
| 值 | 说明 | 适用范围 |
|
||||
|---|-----------------|---------|
|
||||
| 0 | 手动更新 | 目标、关键结果 |
|
||||
| 1 | 基于进度和当前时间自动更新 | 目标、关键结果 |
|
||||
| 2 | 基于风险最高的关键结果状态更新 | 仅目标 |
|
||||
|
||||
### 当前值计算方式 (current_value_calculate_type)
|
||||
|
||||
| 值 | 说明 | 适用范围 |
|
||||
|---|---------------|---------|
|
||||
| 0 | 手动更新 | 目标、关键结果 |
|
||||
| 1 | 基于关键结果进度自动更新 | 仅目标 |
|
||||
| 2 | 基于拆解的关键结果进度更新 | 仅关键结果 |
|
||||
|
||||
### IndicatorUnit (指标单位)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------|-----------|----|-----------------------------------------------------------------------------|
|
||||
| `unit_type` | `integer` | 是 | 单位类型:`0`=公共,`1`=自定义 |
|
||||
| `unit_value` | `string` | 是 | 单位值。公共类型可选:`PERCENT`(百分比)、`NONE`(无单位)、`YUAN`(元)、`DOLLAR`(美元);自定义类型字符长度不超过 5 |
|
||||
|
||||
> **API:**
|
||||
> - `key_result.indicators.list` — 获取关键结果的指标
|
||||
> - `objective.indicators.list` — 获取目标的指标
|
||||
> - `indicators.patch` — 更新指标
|
||||
|
||||
---
|
||||
|
||||
## Alignment (对齐关系)
|
||||
|
||||
对齐关系描述两个目标之间的上下对齐。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------------|-----------|----|-----------------------|
|
||||
| `id` | `string` | 是 | 对齐 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳 |
|
||||
| `from_owner` | `Owner` | 是 | 发起对齐的所有者 |
|
||||
| `to_owner` | `Owner` | 是 | 被对齐的所有者 |
|
||||
| `from_entity_type` | `integer` | 是 | 发起对齐的实体类型,固定为 `2`(目标) |
|
||||
| `from_entity_id` | `string` | 是 | 发起对齐的实体 ID |
|
||||
| `to_entity_type` | `integer` | 是 | 被对齐的实体类型,固定为 `2`(目标) |
|
||||
| `to_entity_id` | `string` | 是 | 被对齐的实体 ID |
|
||||
|
||||
> **API:**
|
||||
> - `alignments.get` — 获取对齐关系
|
||||
> - `alignments.delete` — 删除对齐关系
|
||||
> - `objective.alignments.list` — 批量获取目标下的对齐关系
|
||||
> - `objective.alignments.create` — 创建对齐关系
|
||||
|
||||
---
|
||||
|
||||
## Category (分类)
|
||||
|
||||
分类用于对目标进行分组标记(如"个人 OKR"、"团队 OKR"、"承诺 OKR")等。具体的分类根据租户设置而定。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----------------|----------------|----|-------------------------------------------------------------|
|
||||
| `id` | `string` | 是 | 分类 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳 |
|
||||
| `category_type` | `string` | 是 | 分类类型:`"person"`=个人,`"team"`=团队 |
|
||||
| `enabled` | `boolean` | 是 | 是否启用 |
|
||||
| `color` | `string` | 是 | 颜色标识:`blue`、`purple`、`wathet`、`turquoise`、`indigo`、`orange` |
|
||||
| `name` | `CategoryName` | 是 | 多语言名称 |
|
||||
|
||||
### CategoryName (分类名称)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|----------|----|-----|
|
||||
| `zh` | `string` | 否 | 中文名 |
|
||||
| `en` | `string` | 否 | 英文名 |
|
||||
| `ja` | `string` | 否 | 日文名 |
|
||||
|
||||
> **API:** `categories.list` — 批量获取租户设置的分类列表
|
||||
|
||||
---
|
||||
|
||||
## 通用请求参数
|
||||
|
||||
以下参数在多数 OKR API 中通用:
|
||||
|
||||
| 参数 | 位置 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|---------|----|------------------------|--------------------------------------------------|
|
||||
| `user_id_type` | `query` | 否 | `"open_id"` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `department_id_type` | `query` | 否 | `"open_department_id"` | 部门 ID 类型:`open_department_id` \| `department_id` |
|
||||
| `page_size` | `query` | 否 | `10` | 分页大小,最大 100 |
|
||||
| `page_token` | `query` | 否 | `""` | 分页键,首页传空串 |
|
||||
|
||||
---
|
||||
|
||||
## 权限 Scope 说明
|
||||
|
||||
| Scope | 权限类型 | 说明 |
|
||||
|-----------------------------|------|--------------|
|
||||
| `okr:okr.content:readonly` | 读 | 读取 OKR 内容 |
|
||||
| `okr:okr.content:writeonly` | 写 | 写入/删除 OKR 内容 |
|
||||
| `okr:okr.period:readonly` | 读 | 读取 OKR 周期 |
|
||||
| `okr:okr.setting:read` | 读 | 读取 OKR 设置 |
|
||||
|
||||
所有 OKR API 均支持 `user` 和 `tenant`(应用)两种 access token 类型。
|
||||
|
||||
## 参考
|
||||
|
||||
- [OKR ContentBlock 富文本格式](lark-okr-contentblock.md) — content/notes 字段的富文本结构定义
|
||||
- [okr +cycle-list](lark-okr-cycle-list.md) — 列出用户 OKR 周期
|
||||
- [okr +cycle-detail](lark-okr-cycle-detail.md) — 获取周期下的目标与关键结果
|
||||
@@ -100,6 +100,20 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
- `patch` — 更新自定义分组
|
||||
- `tasks` — 获取自定义分组任务列表
|
||||
|
||||
### custom_fields
|
||||
|
||||
- `create` — 创建自定义字段
|
||||
- `get` — 获取自定义字段详情
|
||||
- `patch` — 更新自定义字段
|
||||
- `list` — 获取自定义字段列表
|
||||
- `add` — 将自定义字段加入资源
|
||||
- `remove` — 将自定义字段移出资源
|
||||
|
||||
### custom_field_options
|
||||
|
||||
- `create` — 创建自定义字段选项
|
||||
- `patch` — 更新自定义字段选项
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -127,3 +141,11 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
| `sections.list` | `task:section:read` |
|
||||
| `sections.patch` | `task:section:write` |
|
||||
| `sections.tasks` | `task:section:read` |
|
||||
| `custom_fields.create` | `task:custom_field:write` |
|
||||
| `custom_fields.get` | `task:custom_field:read` |
|
||||
| `custom_fields.patch` | `task:custom_field:write` |
|
||||
| `custom_fields.list` | `task:custom_field:read` |
|
||||
| `custom_fields.add` | `task:custom_field:write` |
|
||||
| `custom_fields.remove` | `task:custom_field:write` |
|
||||
| `custom_field_options.create` | `task:custom_field:write` |
|
||||
| `custom_field_options.patch` | `task:custom_field:write` |
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
---
|
||||
name: lark-whiteboard-cli
|
||||
description: >
|
||||
当用户要求或使用飞书画板绘制架构图、流程图、思维导图、时序图或其他可视化图表时使用此 skill,作为使用 whiteboard-cli 设计图表布局的指南
|
||||
compatibility: Requires Node.js 18+
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> **环境依赖**:绘制画板需要 `@larksuite/whiteboard-cli`(画板 Node.js CLI 工具),以及 `lark-cli`(LarkSuite CLI 工具)。
|
||||
> 如果执行失败,手动安装后重试:`npm install -g @larksuite/whiteboard-cli@^0.2.0`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 执行 `npm install` 安装新的依赖前,务必征得用户同意!
|
||||
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是画板,不是网页。** 画板是无限画布上自由放置元素,flex 布局是可选增强。
|
||||
|
||||
```
|
||||
Step 1: 路由 & 读取知识
|
||||
- 判断渲染路径(见路由表):Mermaid 还是 DSL?
|
||||
- 读对应 scene 指南 — 了解结构特征和布局策略
|
||||
- 确定布局策略(见下方快速判断)和构建方式
|
||||
- 读 references/ 核心模块 — 语法、布局、配色、排版、连线
|
||||
|
||||
Step 2: 生成完整 DSL(含颜色)
|
||||
- 按 content.md 规划信息量和分组
|
||||
- 按 layout.md 选择布局模式和间距
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.0 --icons` 查看可用图标,选取合适的图标, 但不要过度使用或者所有图表都用图标, 根据图表类型和内容选择是否使用图标
|
||||
- 按 style.md 上色(用户没指定时用默认经典色板)
|
||||
- 按 schema.md 语法输出完整 JSON
|
||||
- 连线参考 connectors.md,排版参考 typography.md
|
||||
|
||||
注意:部分图形(鱼骨/飞轮/柱状/折线等)要按 scene 指南的脚本模板写 .js 脚本生成 JSON:
|
||||
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
|
||||
2. 将脚本保存为 diagram.gen.js,执行 node diagram.gen.js 产出 diagram.json
|
||||
3. 用产出的 diagram.json 进入 Step 3
|
||||
|
||||
Step 3: 渲染 & 审查 → 交付
|
||||
- 渲染前自查(见下方检查清单)
|
||||
- 渲染 PNG,检查:
|
||||
· 信息完整?布局合理?配色协调?
|
||||
· 文字无截断?连线无交叉?
|
||||
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
|
||||
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
|
||||
- 没问题 → 交付:
|
||||
· 用户要求上传飞书 → 见下方”上传飞书画板”章节中的说明
|
||||
· 用户未指定 → 展示 PNG 图片给用户
|
||||
```
|
||||
|
||||
**布局策略快速判断**(详见 `references/layout.md`):
|
||||
|
||||
先定**主布局**,再定子布局:**结构化信息**优先用 Flex,**关系链路**优先用 Dagre,**灵活定位**用绝对布局。
|
||||
|
||||
涉及 Dagre / Flex 的具体边界、危险模式、混合布局原则,统一以 `references/layout.md` 为准;scene 文件只描述场景差异,不重复定义通用布局规则。
|
||||
|
||||
> **构建方式是强约束**:当 scene 指南要求"脚本生成"时,必须先写脚本(.js)并用 `node` 执行来产出 JSON 文件。绝对定位场景(鱼骨图、飞轮图、柱状图、折线图等)的坐标需要数学计算,直接手写 JSON 极易导致节点重叠或连线穿模。
|
||||
---
|
||||
|
||||
## 渲染路径选择(DSL or Mermaid)
|
||||
|
||||
| 图表类型 | 路径 | 理由 |
|
||||
| ------------ | ----------- | ------------------- |
|
||||
| 思维导图 | **Mermaid** | 辐射结构自动布局 |
|
||||
| 时序图 | **Mermaid** | 参与方+消息自动排列 |
|
||||
| 类图 | **Mermaid** | 类关系自动布局 |
|
||||
| 饼图 | **Mermaid** | Mermaid 原生支持 |
|
||||
| 其他所有类型 | **DSL** | 精确控制样式和布局 |
|
||||
|
||||
**路由规则**:
|
||||
1. **自动 Mermaid**:思维导图、时序图、类图、饼图 → 默认走 Mermaid
|
||||
2. **显式 Mermaid**:用户输入包含 Mermaid 语法 → 走 Mermaid
|
||||
3. **DSL 路径**:其他所有类型 → 先读核心模块,再读对应场景指南
|
||||
|
||||
**Mermaid 路径**:参考 `scenes/mermaid.md` 编写 `.mmd` 文件,跳过 DSL 模块。
|
||||
**DSL 路径**:按 Workflow 3 步执行。
|
||||
|
||||
---
|
||||
|
||||
## 模块索引
|
||||
|
||||
### 核心参考(DSL 路径必读)
|
||||
|
||||
| 模块 | 文件 | 说明 |
|
||||
| -------- | -------------------------- | ------------------------------- |
|
||||
| DSL 语法 | `references/schema.md` | 节点类型、属性、尺寸值 |
|
||||
| 内容规划 | `references/content.md` | 信息提取、密度决策、连线预判 |
|
||||
| 布局系统 | `references/layout.md` | 网格方法论、Flex 映射、间距规则 |
|
||||
| 排版规则 | `references/typography.md` | 字号层级、对齐、行距 |
|
||||
| 连线系统 | `references/connectors.md` | 拓扑规划、锚点选择 |
|
||||
| 配色系统 | `references/style.md` | 多色板、视觉层级 |
|
||||
|
||||
|
||||
### 场景指南(按类型选读一个)
|
||||
|
||||
| 图表类型 | 文件 | 适用场景 |
|
||||
| ----------- | ------------------------ | -------------------------------------- |
|
||||
| 架构图 | `scenes/architecture.md` | 分层架构、微服务架构 |
|
||||
| 组织架构图 | `scenes/organization.md` | 公司组织、树形层级 |
|
||||
| 泳道图 | `scenes/swimlane.md` | 跨角色流程、跨系统交互流程、端到端链路 |
|
||||
| 对比图 | `scenes/comparison.md` | 方案对比、功能矩阵 |
|
||||
| 鱼骨图 | `scenes/fishbone.md` | 因果分析、根因分析 |
|
||||
| 柱状图 | `scenes/bar-chart.md` | 柱状图、条形图 |
|
||||
| 折线图 | `scenes/line-chart.md` | 折线图、趋势图 |
|
||||
| 树状图 | `scenes/treemap.md` | 矩形树图、层级占比 |
|
||||
| 漏斗图 | `scenes/funnel.md` | 转化漏斗、销售漏斗 |
|
||||
| 金字塔图 | `scenes/pyramid.md` | 层级结构、需求层次 |
|
||||
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
|
||||
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
|
||||
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
|
||||
| Mermaid | `scenes/mermaid.md` | 思维导图、时序图、类图、饼图 |
|
||||
|
||||
---
|
||||
|
||||
## 文件产物规范
|
||||
|
||||
每次绘图在 `./diagrams/` 下按当前时间创建子目录(格式 `YYYY-MM-DDTHHMMSS`),目录内文件名固定。用户指定了保存路径时以用户为准。
|
||||
|
||||
```
|
||||
./diagrams/
|
||||
2026-03-27T143000/ ← 自动按时间创建,无需起名
|
||||
diagram.json ← DSL(CLI 输入)
|
||||
diagram.gen.js ← 坐标计算脚本(仅脚本构建方式)
|
||||
diagram.png ← 最终图片
|
||||
diagram.mmd ← Mermaid 源码(仅 Mermaid 路径)
|
||||
```
|
||||
|
||||
## CLI 命令
|
||||
|
||||
**查看可用图标**:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 --icons
|
||||
```
|
||||
|
||||
**渲染**:
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i ./diagrams/2026-03-27T143000/diagram.json -o ./diagrams/2026-03-27T143000/diagram.png # DSL
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i ./diagrams/2026-03-27T143000/diagram.mmd -o ./diagrams/2026-03-27T143000/diagram.png # Mermaid
|
||||
```
|
||||
|
||||
**上传飞书画板**:
|
||||
|
||||
> 上传需要飞书认证。遇到认证或权限错误时,阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 了解登录和权限处理。
|
||||
|
||||
**第一步:获取画板 Token**
|
||||
|
||||
| 用户给了什么 | 怎么获取 Token |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 画板 Token(`XXX`) | 直接使用 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token=”XXX”/>` 中提取 token |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type=”blank”></whiteboard>' --as user`,从响应的 `data.board_tokens[0]` 获取 token |
|
||||
|
||||
关于飞书文档的创建,读取等更多操作,请参考 lark-doc skill [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md)。
|
||||
|
||||
**第二步:上传**
|
||||
|
||||
> [!CAUTION]
|
||||
> **MANDATORY PRE-FLIGHT CHECK (上传前强制拦截检查)**
|
||||
> 当你要向一个**已存在的画板 Token** 写入内容时,**绝对禁止**直接执行上传命令!你必须严格遵守以下两步:
|
||||
> **强制执行 Dry Run(状态探测)**
|
||||
> 必须先在命令中添加 `--overwrite --dry-run` 参数来探测画板当前状态。示例命令:
|
||||
> ```bash
|
||||
> npx -y @larksuite/whiteboard-cli@^0.2.0 --to openapi -i <输入文件> --format json | lark-cli whiteboard +update --whiteboard-token <Token> --source - --overwrite --dry-run --as user
|
||||
> ```
|
||||
>
|
||||
> **解析结果并拦截**
|
||||
> - 仔细阅读 Dry Run 的输出日志。
|
||||
> - **如果日志包含 `XX whiteboard nodes will be deleted`**:这说明画板**非空**,当前操作会覆盖并摧毁用户的原有图表!
|
||||
> - **你必须立即停止操作**,并通过 `AskUserQuestion` 工具(或直接回复)向用户确认:”目标画板当前非空,继续更新将清空原有的 XX 个节点,是否确认覆盖?”
|
||||
> - 只有在用户明确授权”同意覆盖”后,你才能移除 `--dry-run` 真正执行上传。
|
||||
> - 用户可能会要求你不覆盖更新画板内容,在这种情况下,移除 `--overwrite` 和 `--dry-run` 参数再上传。
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 --to openapi -i <输入文件> --format json | lark-cli whiteboard +update --whiteboard-token <画板Token> --source - --yes --as user
|
||||
```
|
||||
> 画板一经上传不可修改。如需应用身份上传,将 `--as user` 替换为 `--as bot`。
|
||||
> 如果画板非空,先加 `--overwrite --dry-run` 检查待删除节点数,向用户确认后去掉 `--dry-run` 执行。
|
||||
|
||||
你也可以将布局输出为原生 OpenAPI json 格式,再通过 lark-cli 导入飞书画板。关于 lark-cli 操作画板的更多方式,请参照 [../lark-whiteboard/SKILL.md](../lark-whiteboard/SKILL.md)
|
||||
|
||||
**症状→修复表**(视觉审查发现问题时参照):
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
| ------------------ | ----------------------------------- |
|
||||
| 文字被截断 | height 改为 fit-content |
|
||||
| 文字溢出容器右侧 | 增大 width,或缩短文字 |
|
||||
| 节点重叠粘连 | 增大 gap |
|
||||
| 节点挤成一团 | 增大 padding 和 gap |
|
||||
| 连线穿过节点 | 调整 fromAnchor/toAnchor 或增大间距 |
|
||||
| 大面积空白 | 缩小外层 frame 宽度 |
|
||||
| 文字和背景色太接近 | 调整 fillColor 或 textColor |
|
||||
| 布局整体偏左/偏右 | 调整绝对定位的 x 坐标使内容居中 |
|
||||
|
||||
---
|
||||
|
||||
## 渲染前自查
|
||||
|
||||
生成 DSL 后、渲染前,快速检查:
|
||||
|
||||
- [ ] 不同分组用了不同颜色?同组节点样式完全一致?
|
||||
- [ ] 外层浅色背景、内层白色节点?(外重内轻)
|
||||
- [ ] 所有节点有边框(borderWidth=2)?文字在背景上清晰可读?
|
||||
- [ ] 连线用灰色(#BBBFC4),不用彩色?
|
||||
- [ ] frame 都写了 layout 属性?gap 和 padding 都显式设置了?
|
||||
- [ ] 含文字节点 height 用 fit-content?connector 在顶层 nodes 数组?
|
||||
|
||||
---
|
||||
|
||||
## 关键约束速查
|
||||
|
||||
> 最高频出错的规则,即使不读子模块文件也必须遵守。
|
||||
|
||||
1. **含文字节点的 height 必须用 `'fit-content'`** — 写死数值会截断文字
|
||||
2. **`fill-container` 仅在 flex 父容器中生效** — `layout: 'none'` 下宽度退化为 0
|
||||
3. **`layout: 'none'` 的容器必须有固定宽高** — 不要写成 `fit-content`
|
||||
4. **connector 必须放在顶层 nodes 数组** — 不能嵌套在 frame children 里
|
||||
5. **图层顺序** — 数组顺序 = 绘制顺序。后定义的元素层级越高,会覆盖先定义的。重叠/浮层/标注元素务必放在数组末尾。
|
||||
6. **flex 容器内的 x/y 会被完全忽略** — 需要自由定位时用 `layout: 'none'` 或放在顶层 nodes
|
||||
7. **Dagre 子容器默认为不透明节点** — 外层连线无法寻址其内部子节点(引擎会自动重定向至外壳)。需穿透时声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`
|
||||
|
||||
❌ 致命错误:flex 容器内设 x/y,坐标不生效,节点按顺序排列
|
||||
```json
|
||||
{ "type": "frame", "layout": "vertical", "children": [
|
||||
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
|
||||
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
|
||||
]}
|
||||
```
|
||||
✅ 正确:用 `layout: "none"` 或放在顶层 nodes 用 x/y 定位。
|
||||
|
||||
❌ 致命错误:`layout: "none"` 容器本身写 `width: "fit-content", height: "fit-content"`,再在内部摆绝对坐标节点
|
||||
|
||||
✅ 正确:绝对定位容器先给固定宽高,再在内部用 x/y 放置子节点。
|
||||
@@ -2,117 +2,144 @@
|
||||
name: lark-whiteboard
|
||||
version: 1.0.0
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 PlantUML/Mermaid 代码或 OpenAPI 原生格式更新画板内容。
|
||||
当用户需要查看画板内容、导出画板图片、或编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill,无论是否提及"画板"。
|
||||
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL(转成 OpenAPI 格式)、PlantUML/Mermaid 格式更新画板内容。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill,无论是否提及"画板"。
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli whiteboard --help"
|
||||
---
|
||||
|
||||
# whiteboard (v1)
|
||||
> [!IMPORTANT]
|
||||
> **执行前检查环境**:
|
||||
> - 运行 `whiteboard-cli --version`,确认版本为 `0.2.x`;未安装或版本不符 → `npm install -g @larksuite/whiteboard-cli@^0.2.0`
|
||||
> - 运行 `lark-cli --version`,确认可用。
|
||||
> - 执行任何 `npm install` 前,**必须征得用户同意**。
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 画板 Token
|
||||
|
||||
画板 token 是画板的唯一标识符。飞书画板嵌入在云文档中,可以从云文档的 `docs +fetch` 结果中获取(`<whiteboard token="xxx"/>`
|
||||
标签),或从 `docs +update` 新建画板后的 `data.board_tokens` 字段中获取。
|
||||
---
|
||||
|
||||
## 快速决策
|
||||
|
||||
当需要插入图表时:
|
||||
| 用户需求 | 行动 |
|
||||
|---|---|
|
||||
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
|
||||
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| 修改节点文字/颜色(简单改动)| `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
|
||||
| 用户**已提供** Mermaid/PlantUML 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml`](references/lark-whiteboard-update.md) |
|
||||
| 绘制复杂图表(架构/流程/组织等)| → **[§ 创作 Workflow](#创作-workflow)** |
|
||||
| 修改/重绘已有复杂画板 | → **[§ 修改 Workflow](#修改-workflow)** |
|
||||
|
||||
1. 能否使用飞书画板?
|
||||
- 能 → 走画板路径(推荐!可编辑、可协作)
|
||||
- 不能 → 走图片路径
|
||||
|
||||
| 用户需求 | 推荐 Shortcut |
|
||||
|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| "查看这个画板的内容" | [`+query --output_as image`](references/lark-whiteboard-query.md) |
|
||||
| "导出画板为图片" | [`+query --output_as image`](references/lark-whiteboard-query.md) |
|
||||
| "获取画板的 PlantUML/Mermaid 代码" | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| "检查画板是否由 PlantUML/Mermaid 代码块组成" | [`+query --output_as code`](references/lark-whiteboard-query.md) |
|
||||
| "修改画板某个节点的颜色或文字" | [`+query --output_as raw`](references/lark-whiteboard-query.md) 后 [`+update`](references/lark-whiteboard-update.md) |
|
||||
| "用 PlantUML 绘制画板" | [`+update --input_format plantuml`](references/lark-whiteboard-update.md) |
|
||||
| "用 Mermaid 绘制画板" | [`+update --input_format mermaid`](references/lark-whiteboard-update.md) |
|
||||
| "在画板绘制复杂图表" | [`+update --input_format raw`](references/lark-whiteboard-update.md), 需要使用 whiteboard-cli 工具,参见 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) |
|
||||
> **⚠️ 强制规范(通过 stdin 更新)**:
|
||||
> 数据来源于本地文件时,**必须**使用 `--source - --input_format <格式>`。
|
||||
> 例:`cat chart.mmd | lark-cli whiteboard +update <token> --source - --input_format mermaid`
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|---------------------------------------------------|---------------------------------------------|
|
||||
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |
|
||||
| [`+update`](references/lark-whiteboard-update.md) | 更新画板内容,支持 PlantUML、Mermaid 或 OpenAPI 原生格式输入 |
|
||||
| Shortcut | 说明 |
|
||||
|---|---|
|
||||
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |
|
||||
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid 或 OpenAPI 原生格式 |
|
||||
|
||||
## Workflow
|
||||
---
|
||||
|
||||
### 场景 1: 创作一个画板
|
||||
## 创作 Workflow
|
||||
|
||||
1. 确定需要创作的画板 Token(从用户请求或对应的文档中获取)与要创作的内容
|
||||
2. 参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 生成画板内容
|
||||
3. 使用 [`+update`](references/lark-whiteboard-update.md) shortcut 更新画板内容
|
||||
> 此 workflow 用于**独立创作一个画板**。
|
||||
> 需要在文档中批量创建多个画板时,由 lark-doc 负责调度,见 `lark-doc` 技能的 `references/lark-doc-whiteboard.md`。
|
||||
|
||||
### 场景 2: 修改或优化一个画板
|
||||
**Step 1:获取 board_token**
|
||||
|
||||
1. 确定要修改的画板 Token (从用户请求或对应的文档中获取)
|
||||
2. 使用 [`+query --output_as code`](references/lark-whiteboard-query.md) shortcut 导出画板代码,确认画板是否由 Mermaid 或
|
||||
PlantUML 绘制
|
||||
1. 如果 +query --output_as code 返回了 Mermaid / PlantUML 代码块,则在这一代码的基础上优化修改
|
||||
2. 如果没有返回代码块,则使用 [`+query --output_as image`](references/lark-whiteboard-query.md)
|
||||
获取画板预览图片,根据图片内容参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 重绘优化
|
||||
3. 如果用户只需要简单修改某个节点的文本内容/颜色,可以使用 [
|
||||
`+query --output_as raw`](references/lark-whiteboard-query.md) shortcut 导出画板原生 OpenAPI 格式,并在此基础上修改。
|
||||
4. 如果用户有明确要求,则以用户要求优先。
|
||||
3. 使用 [`+update`](references/lark-whiteboard-update.md) shortcut 创建新的画板内容。根据用户需求,你可能会需要使用 [
|
||||
`docs +update`](../lark-doc/references/lark-doc-update.md) 创建新的画板,或使用 [
|
||||
`+update --overwrite`](references/lark-whiteboard-update.md) 在原画板上覆盖式更新。
|
||||
| 用户给了什么 | 怎么获取 |
|
||||
|---|---|
|
||||
| 直接给了 whiteboard token(`wbcnXXX`)| 直接使用 |
|
||||
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
|
||||
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md)|
|
||||
|
||||
## 与 lark-doc 的配合使用
|
||||
**Step 2:渲染 & 写入**
|
||||
|
||||
### 场景 1: 从文档中获取画板 token
|
||||
→ 进入 **[§ 渲染 & 写入画板](#渲染--写入画板)** 章节,按流程完成后直接返回结果给用户。
|
||||
|
||||
1. 使用 `lark-doc` 的 [`+fetch`](../lark-doc/references/lark-doc-fetch.md) 获取文档内容
|
||||
2. 从返回的 markdown 中解析 `<whiteboard token="xxx"/>` 标签,记录画板 token
|
||||
3. 使用本 skill 的 `+query` 或 `+update` 读取或操作画板
|
||||
---
|
||||
|
||||
### 场景 2: 新建画板并编辑(完整流程)
|
||||
## 修改 Workflow
|
||||
|
||||
这是最常见的使用场景,**必须完整执行以下步骤**:
|
||||
**Step 1:获取 board_token**(同创作 Workflow Step 1)
|
||||
|
||||
1. 使用 `lark-doc` 的 [`+update`](../lark-doc/references/lark-doc-update.md) 创建空白画板
|
||||
- 在 markdown 中传入 `<whiteboard type="blank"></whiteboard>`
|
||||
- **注意这一 XML 标签不要转义**
|
||||
- 需要多个画板时,重复多个 whiteboard 标签
|
||||
**Step 2:判断修改策略**
|
||||
|
||||
2. 从响应的 `data.board_tokens` 中获取新建画板的 token 列表
|
||||
- 记录每个 token 对应的图表类型和位置
|
||||
```
|
||||
+query --output_as code
|
||||
├─ 返回 Mermaid/PlantUML 代码
|
||||
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
|
||||
├─ 无代码(DSL 或其他方式绘制的画板)
|
||||
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
|
||||
│ └─ 重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
|
||||
└─ 用户有明确要求 → 以用户要求优先
|
||||
```
|
||||
|
||||
3. 根据文档主题,为每个画板设计相应的内容
|
||||
- 参考下方"常见图表模板与参考指南"选择合适的语法
|
||||
- 使用 Mermaid(推荐)、PlantUML 或 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 生成内容
|
||||
---
|
||||
|
||||
4. **逐个更新画板**:使用本 skill 的 `+update` shortcut 编辑每个画板的内容
|
||||
- 不要遗漏任何一个画板 token
|
||||
- 确保每个画板都有实际内容,不是空白
|
||||
## 渲染 & 写入画板
|
||||
|
||||
### 常见图表模板与参考指南
|
||||
### 渲染路由
|
||||
|
||||
| 图表类型 | 推荐语法 | 详细参考指南 |
|
||||
|----------------|--------------------|---------------------------------------------------------------------------------------------|
|
||||
| 架构图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/architecture.md](../lark-whiteboard-cli/scenes/architecture.md) |
|
||||
| 流程图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/flowchart.md](../lark-whiteboard-cli/scenes/flowchart.md) |
|
||||
| 组织架构图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/organization.md](../lark-whiteboard-cli/scenes/organization.md) |
|
||||
| 里程碑/时间线 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/milestone.md](../lark-whiteboard-cli/scenes/milestone.md) |
|
||||
| 鱼骨图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/fishbone.md](../lark-whiteboard-cli/scenes/fishbone.md) |
|
||||
| 对比图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/comparison.md](../lark-whiteboard-cli/scenes/comparison.md) |
|
||||
| 飞轮图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/flywheel.md](../lark-whiteboard-cli/scenes/flywheel.md) |
|
||||
| 金字塔图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/pyramid.md](../lark-whiteboard-cli/scenes/pyramid.md) |
|
||||
| 思维导图/饼图/时序图/类图 | Mermaid | [lark-whiteboard-cli/scenes/mermaid.md](../lark-whiteboard-cli/scenes/mermaid.md) |
|
||||
| 柱状图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/bar-chart.md](../lark-whiteboard-cli/scenes/bar-chart.md) |
|
||||
| 折线图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/line-chart.md](../lark-whiteboard-cli/scenes/line-chart.md) |
|
||||
| 树状图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/treemap.md](../lark-whiteboard-cli/scenes/treemap.md) |
|
||||
| 漏斗图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/funnel.md](../lark-whiteboard-cli/scenes/funnel.md) |
|
||||
| 泳道图 | whiteboard-cli DSL | [lark-whiteboard-cli/scenes/swimlane.md](../lark-whiteboard-cli/scenes/swimlane.md) |
|
||||
**先自报身份**:按训练来源,你属于哪一家?`Claude` / `Gemini` / `GPT` / `GLM` / `Doubao 或 Seed` / `Other`。身份决定 SVG / DSL 分流(不同家族对这两种表达语言的能力不均衡),不要跳过这一步。
|
||||
|
||||
**然后按图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
|
||||
|
||||
| 图表类型 | 身份 | 路径 |
|
||||
|---|---|---|
|
||||
| 思维导图、时序图、类图、饼图、甘特图 | 任何身份 | [`routes/mermaid.md`](routes/mermaid.md) |
|
||||
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`routes/svg.md`](routes/svg.md) |
|
||||
| 其他图表 | `Doubao` / `Seed` / `Other` | [`routes/dsl.md`](routes/dsl.md) |
|
||||
|
||||
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG,改读 `routes/dsl.md` 从零重画,不要逐行修补**:
|
||||
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error)
|
||||
> - 两轮改写仍无法消除 `--check` 的 `text-overflow` error
|
||||
> - 目测 PNG 视觉严重错乱(文字大面积溢出、元素重叠压住关键信息、布局整体崩溃)
|
||||
>
|
||||
> SVG 源码修补常常引入新 bug,换 DSL 从零重画往往更稳。这是 SVG 路径自由发挥的硬兜底,不要侵入 `routes/svg.md` 的创作流程。
|
||||
|
||||
### 产物规范
|
||||
|
||||
产物目录:`./diagrams/YYYY-MM-DDTHHMMSS/`(本地时间,不含冒号和时区后缀)。如用户指定路径,以用户为准。
|
||||
|
||||
目录内固定文件名:
|
||||
|
||||
```
|
||||
diagram.svg ← SVG 源码(SVG 路径)
|
||||
diagram.mmd ← Mermaid 源码(Mermaid 路径)
|
||||
diagram.json ← DSL 源文件(DSL 路径) / OpenAPI JSON(SVG 路径从 diagram.svg 导出)
|
||||
diagram.gen.cjs ← 坐标计算脚本(仅 DSL 脚本构建方式)
|
||||
diagram.png ← 渲染结果
|
||||
```
|
||||
|
||||
### 写入画板
|
||||
|
||||
> [!CAUTION]
|
||||
> **写入前强制 dry-run**:向已有内容的画板写入时,必须先加 `--overwrite --dry-run` 探测。
|
||||
> 输出含 `XX whiteboard nodes will be deleted` → 必须向用户确认后才能执行。
|
||||
|
||||
```bash
|
||||
# 第一步:dry-run 探测
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <Token> \
|
||||
--source - --input_format raw \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--overwrite --dry-run --as user
|
||||
|
||||
# 第二步:确认后执行
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <Token> \
|
||||
--source - --input_format raw \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--overwrite --as user
|
||||
```
|
||||
|
||||
> `--idempotent-token` 最少 10 字符,建议用时间戳+标识拼接(如 `1744800000-board-1`),避免重试导致重复写入。
|
||||
> 如需应用身份上传,将 `--as user` 替换为 `--as bot`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# whiteboard +query(查询画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询画板内容,支持导出为预览图片、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
- `image`:预览图片
|
||||
- `code`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md) 。
|
||||
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
|
||||
|
||||
## 示例
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# whiteboard +update(更新画板)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
更新画板内容,支持三种输入格式:
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
|
||||
思维导图,时序图,类图,饼图,流程图等图标推荐使用 Mermaid/PlantUML 语法绘制。
|
||||
|
||||
而当需要绘制架构图,组织架构图,泳道图,对比图,鱼骨图,柱状图,折线图,树状图,漏斗图,金字塔图,循环/飞轮图,里程碑或其他较为复杂的图表时,推荐参考 [lark-whiteboard-cli](../lark-whiteboard-cli/SKILL.md)
|
||||
使用 whiteboard-cli 工具创作。
|
||||
而当需要绘制架构图,组织架构图,泳道图,对比图,鱼骨图,柱状图,折线图,树状图,漏斗图,金字塔图,循环/飞轮图,里程碑或其他较为复杂的图表时,推荐参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板) 使用 whiteboard-cli 工具创作。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -69,28 +68,32 @@ lark-cli whiteboard +update \
|
||||
--overwrite --yes --as user
|
||||
```
|
||||
|
||||
### 示例 3:使用 whiteboard-cli 直接使用画板 DSL 更新画板
|
||||
### 示例 3:使用 whiteboard-cli 生成 OpenAPI 格式并写入画板
|
||||
|
||||
whiteboard-cli 工具的具体用法请参考 [../lark-whiteboard-cli/SKILL.md](../lark-whiteboard-cli/SKILL.md)
|
||||
whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)
|
||||
|
||||
```bash
|
||||
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <画板 DSL> --format json | lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--input_format raw --source -\
|
||||
--overwrite --yes --as user
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i <产物文件> --to openapi --format json \
|
||||
| lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--source - --input_format raw \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--yes --as user
|
||||
```
|
||||
|
||||
### 示例 4:使用 whiteboard-cli 使用画板 DSL 生成 Raw 格式 Json,并使用其更新画板
|
||||
### 示例 4:先生成产物文件,再从文件读取更新
|
||||
|
||||
whiteboard-cli 工具的具体用法请参考 [../lark-whiteboard-cli/SKILL.md](../lark-whiteboard-cli/SKILL.md)
|
||||
whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)
|
||||
|
||||
```bash
|
||||
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
|
||||
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <画板 DSL> -o ./temp.json
|
||||
# 生成 OpenAPI 格式到文件
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i <DSL 文件> --to openapi --format json -o ./temp.json
|
||||
|
||||
# 从文件读取并更新
|
||||
lark-cli whiteboard +update \
|
||||
--whiteboard-token <画板Token> \
|
||||
--idempotent-token <10+字符唯一串> \
|
||||
--input_format raw \
|
||||
--source @./temp.json \
|
||||
--overwrite --yes --as user
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||
| 纯 Flex / Dagre | 直接写 JSON |
|
||||
| 混合布局 (Flex包Dagre) | 直接写 JSON(外层先做分区,局部复杂关系交给 Dagre;若被嵌套,默认为不透明节点) |
|
||||
| 极度依赖几何坐标的图 | 写脚本生成 JSON(node xxx.js) |
|
||||
| 极度依赖几何坐标的图 | 写脚本生成 JSON(node xxx.cjs) |
|
||||
| 需要精确避让的特殊线 | 脚本 + `--layout` 两阶段 |
|
||||
|
||||
---
|
||||
106
skills/lark-whiteboard/routes/dsl.md
Normal file
106
skills/lark-whiteboard/routes/dsl.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# DSL 路径
|
||||
|
||||
> **这是画板,不是网页。** 画板是无限画布上自由放置元素,flex 布局是可选增强。
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Step 1: 路由 & 读取知识
|
||||
- 读对应 scene 指南 — 了解结构特征和布局策略
|
||||
- 确定布局策略(见下方快速判断)和构建方式
|
||||
- 读 references/ 核心模块 — 语法、布局、配色、排版、连线
|
||||
|
||||
Step 2: 生成完整 DSL(含颜色)
|
||||
- 按 content.md 规划信息量和分组
|
||||
- 按 layout.md 选择布局模式和间距
|
||||
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.0 --icons` 查看可用图标
|
||||
- 按 style.md 上色(用户没指定时用默认经典色板)
|
||||
- 按 schema.md 语法输出完整 JSON
|
||||
- 连线参考 connectors.md,排版参考 typography.md
|
||||
|
||||
注意:部分图形(鱼骨/飞轮/柱状/折线等)要按 scene 指南的脚本模板写 CommonJS 脚本生成 JSON:
|
||||
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
|
||||
2. 将脚本保存为 diagram.gen.cjs(必须 .cjs 后缀,脚本用 require() 写,.js 在 ESM 项目下会崩),执行 node diagram.gen.cjs 产出 diagram.json
|
||||
3. 用产出的 diagram.json 进入 Step 3
|
||||
|
||||
Step 3: 渲染 & 审查 → 交付
|
||||
- 渲染前自查(见下方检查清单)
|
||||
- 渲染 PNG(仅用于预览验证,不是最终产物):npx -y @larksuite/whiteboard-cli@^0.2.0 -i diagram.json -o diagram.png
|
||||
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
|
||||
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
|
||||
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
|
||||
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update:
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i diagram.json --to openapi --format json \
|
||||
| lark-cli whiteboard +update --whiteboard-token <board_token> \
|
||||
--source - --input_format raw --idempotent-token <时间戳+标识> --yes --as user
|
||||
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)
|
||||
- 交付:向用户报告 board_token 写入成功
|
||||
```
|
||||
|
||||
**布局策略快速判断**(详见 `references/layout.md`):
|
||||
|
||||
先定**主布局**,再定子布局:**结构化信息**优先用 Flex,**关系链路**优先用 Dagre,**灵活定位**用绝对布局。
|
||||
|
||||
> **构建方式是强约束**:当 scene 指南要求"脚本生成"时,必须先写脚本(`.cjs`,CommonJS)并用 `node` 执行来产出 JSON 文件。
|
||||
|
||||
## 模块索引
|
||||
|
||||
### 核心参考(必读)
|
||||
|
||||
| 模块 | 文件 | 说明 |
|
||||
| -------- | -------------------------- | ------------------------------- |
|
||||
| DSL 语法 | `references/schema.md` | 节点类型、属性、尺寸值 |
|
||||
| 内容规划 | `references/content.md` | 信息提取、密度决策、连线预判 |
|
||||
| 布局系统 | `references/layout.md` | 网格方法论、Flex 映射、间距规则 |
|
||||
| 排版规则 | `references/typography.md` | 字号层级、对齐、行距 |
|
||||
| 连线系统 | `references/connectors.md` | 拓扑规划、锚点选择 |
|
||||
| 配色系统 | `references/style.md` | 多色板、视觉层级 |
|
||||
|
||||
### 场景指南(按类型选读一个)
|
||||
|
||||
| 图表类型 | 文件 | 适用场景 |
|
||||
| ----------- | ------------------------ | -------------------------------------- |
|
||||
| 架构图 | `scenes/architecture.md` | 分层架构、微服务架构 |
|
||||
| 组织架构图 | `scenes/organization.md` | 公司组织、树形层级 |
|
||||
| 泳道图 | `scenes/swimlane.md` | 跨角色流程、跨系统交互流程 |
|
||||
| 对比图 | `scenes/comparison.md` | 方案对比、功能矩阵 |
|
||||
| 鱼骨图 | `scenes/fishbone.md` | 因果分析、根因分析 |
|
||||
| 柱状图 | `scenes/bar-chart.md` | 柱状图、条形图 |
|
||||
| 折线图 | `scenes/line-chart.md` | 折线图、趋势图 |
|
||||
| 树状图 | `scenes/treemap.md` | 矩形树图、层级占比 |
|
||||
| 漏斗图 | `scenes/funnel.md` | 转化漏斗、销售漏斗 |
|
||||
| 金字塔图 | `scenes/pyramid.md` | 层级结构、需求层次 |
|
||||
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
|
||||
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
|
||||
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
|
||||
|
||||
## 渲染前自查
|
||||
|
||||
- [ ] 不同分组用了不同颜色?同组节点样式完全一致?
|
||||
- [ ] 外层浅色背景、内层白色节点?
|
||||
- [ ] 所有节点有边框(borderWidth=2)?文字在背景上清晰可读?
|
||||
- [ ] 连线用灰色(#BBBFC4),不用彩色?
|
||||
- [ ] frame 都写了 layout 属性?gap 和 padding 都显式设置了?
|
||||
- [ ] 含文字节点 height 用 fit-content?connector 在顶层 nodes 数组?
|
||||
|
||||
## 症状→修复表
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
| ------------------ | ----------------------------------- |
|
||||
| 文字被截断 | height 改为 fit-content |
|
||||
| 文字溢出容器右侧 | 增大 width,或缩短文字 |
|
||||
| 节点重叠粘连 | 增大 gap |
|
||||
| 节点挤成一团 | 增大 padding 和 gap |
|
||||
| 连线穿过节点 | 调整 fromAnchor/toAnchor 或增大间距 |
|
||||
| 大面积空白 | 缩小外层 frame 宽度 |
|
||||
| 文字和背景色太接近 | 调整 fillColor 或 textColor |
|
||||
| 布局整体偏左/偏右 | 调整绝对定位的 x 坐标使内容居中 |
|
||||
|
||||
## 关键约束速查
|
||||
|
||||
1. **含文字节点的 height 必须用 `'fit-content'`** — 写死数值会截断文字
|
||||
2. **`fill-container` 仅在 flex 父容器中生效** — `layout: 'none'` 下宽度退化为 0
|
||||
3. **`layout: 'none'` 的容器必须有固定宽高** — 不要写成 `fit-content`
|
||||
4. **connector 必须放在顶层 nodes 数组** — 不能嵌套在 frame children 里
|
||||
5. **flex 容器内的 x/y 会被完全忽略** — 需要自由定位时用 `layout: 'none'`
|
||||
6. **Dagre 子容器默认为不透明节点** — 需穿透时声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`
|
||||
27
skills/lark-whiteboard/routes/mermaid.md
Normal file
27
skills/lark-whiteboard/routes/mermaid.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Mermaid 路径
|
||||
|
||||
适用于:思维导图、时序图、类图、饼图、甘特图。
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Step 1: 读取知识
|
||||
- 读 scenes/mermaid.md — Mermaid 语法和使用方式
|
||||
|
||||
Step 2: 生成 Mermaid
|
||||
- 按 mermaid.md 的语法编写 .mmd 文件
|
||||
- 只输出纯 Mermaid 语法文本
|
||||
|
||||
Step 3: 渲染验证 & 写入画板 & 交付
|
||||
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
|
||||
2. 保存为 diagram.mmd
|
||||
3. 渲染(仅用于预览验证,PNG 不是最终产物):
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i diagram.mmd -o diagram.png
|
||||
4. 审查 PNG,有问题修改后重新渲染(最多 2 轮)
|
||||
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update:
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i diagram.mmd --to openapi --format json \
|
||||
| lark-cli whiteboard +update --whiteboard-token <board_token> \
|
||||
--source - --input_format raw --idempotent-token <时间戳+标识> --yes --as user
|
||||
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)
|
||||
6. 交付:向用户报告 board_token 写入成功
|
||||
```
|
||||
53
skills/lark-whiteboard/routes/svg.md
Normal file
53
skills/lark-whiteboard/routes/svg.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# SVG 路径
|
||||
|
||||
你在设计一张专业的信息图——内容扎实, 美观漂亮, 具有设计感和视觉张力, 不是枯燥的布局和文字堆砌, **不要做的像普通的网页或者千篇一律的模版**
|
||||
最终交付是**画板跨越重排渲染的节点**(你写 SVG → 画板解析)
|
||||
|
||||
**核心心智纠正 (重要)**:
|
||||
- 大多数 AI 如果只考虑“绝对不报错/完美映射”, 最终给出的都是全篇纯白底色加单层 `<rect>` 的方正卡片网格, 极其死板单调, **这将被视为不及格!**
|
||||
- **SVG 给你了完全的设计自由**, 请大胆使用你脑内的图标路径 (`<path>`), 连接指引 (`流畅的 <path>`), 各种环境氛围点缀, 大胆一点, 充分信任你的品味, 发挥出你的顶级艺术创造力!
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 想清楚要画什么
|
||||
|
||||
- **核心信息是什么?** 能做到一图胜千言, 绝对不要只生成平平无奇的文字表格, 要有设计感
|
||||
- **内容充实度**:如果用户描述稀疏简略, 利用你的领域知识扩展, 保证信息维度和内容充实, 但不要过度堆砌, 淹没重点
|
||||
- **视觉层级与隐喻**:这个没有固定的形式, 你自由判断, 比如: 给重要的节点加光环, 加高亮背景;给对比项设计天平或对称结构
|
||||
|
||||
### 2. 写 SVG
|
||||
|
||||
[!IMPORTANT] 布局, 配色, 信息密度, 装饰物——**全部由你判断**, 打破单调的 `<rect>` 牢笼, 严禁通篇用矩形和文字应付用户
|
||||
|
||||
操作边界约束:
|
||||
- **语言跟随用户**:图表文字的语言与用户 prompt 保持一致, 技术术语用行业里通用的写法, 不机械翻译
|
||||
- 文字用 `<text>`(不是 `<path>`), 容器宽度留够——画板按 CJK ≈ 1em / Latin ≈ 0.6em 重排
|
||||
- 连线使用正交折线替代斜直线(`<polyline>` 带水平/垂直折点)视觉效果更好
|
||||
- 可自由使用 `translate`, `rotate`, `scale`但请尽量避免使用 `skewX` / `skewY` / `matrix(...)` 发生空间级扭曲
|
||||
|
||||
### 3. 渲染审查
|
||||
|
||||
```
|
||||
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
|
||||
写文件 <dir>/diagram.svg
|
||||
渲染 npx -y @larksuite/whiteboard-cli@^0.2.0 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
|
||||
检查 npx -y @larksuite/whiteboard-cli@^0.2.0 -i <dir>/diagram.svg -f svg --check
|
||||
导出 npx -y @larksuite/whiteboard-cli@^0.2.0 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
|
||||
```
|
||||
|
||||
`npx -y @larksuite/whiteboard-cli@^0.2.0 --check` 检测 `text-overflow` 和 `node-overlap`, 并结合视觉效果(查看 PNG)进行调整
|
||||
|
||||
## 画板怎么处理 SVG
|
||||
|
||||
画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示), **不需要所有元素都可编辑, 一定要兼顾可编辑和美观漂亮**
|
||||
|
||||
**可识别的元素**
|
||||
|
||||
- 形状:`<rect>` / `<circle>` / `<ellipse>` / `<polygon>`
|
||||
- 连线:`<line>` / `<polyline>` / `<path>`(自动识别为直线 / 折线 / 曲线)
|
||||
- 文本:`<text>` / `<tspan>` 画板硬编码 Noto Sans SC **文字必须用 `<text>`**
|
||||
- 分组:`<g>` / `<a>` / `<use>` 引用 `<symbol>`
|
||||
- 变换:`translate` / `rotate` / `scale` 正常;`skewX` / `skewY` / `matrix(...)` 降级
|
||||
|
||||
**装饰特性**
|
||||
- `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>` → 画板通过图片路径保留视觉 (光晕/阴影/纹理/遮罩等效果都在, 元素不可再编辑但不丢视觉)
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .js 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
|
||||
|
||||
## Layout 规则
|
||||
@@ -179,3 +179,9 @@
|
||||
- 柱体间距不均匀(脚本需统一计算 barGap)
|
||||
- Y 轴刻度线和格线误带箭头
|
||||
- 坐标轴忘记带箭头
|
||||
|
||||
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `data` 数组,其余坐标与柱体高度全自动计算。
|
||||
|
||||
```javascript
|
||||
const { writeFileSync } = require('fs');
|
||||
```
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .js 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(必须):用 .js 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -51,7 +51,7 @@ nodes 数组中的图层顺序(必须严格遵守):
|
||||
|
||||
## 骨架示例
|
||||
|
||||
此场景必须用 .js 脚本生成。Agent 使用时只需修改 `stages` 数组和 `centerTitle`/`centerSubtitle`,其余坐标全自动计算。
|
||||
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `stages` 数组和 `centerTitle`/`centerSubtitle`,其余坐标全自动计算。
|
||||
|
||||
```javascript
|
||||
const { writeFileSync } = require('fs');
|
||||
@@ -26,10 +26,10 @@
|
||||
|
||||
## 脚本构建模板
|
||||
|
||||
必须使用 `node` 运行脚本生成 JSON。
|
||||
此场景必须用 .cjs 脚本生成。
|
||||
|
||||
```javascript
|
||||
import fs from 'fs';
|
||||
const fs = require('fs');
|
||||
|
||||
// 1. 配置基础参数
|
||||
const GAP = 4;
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):用 .js 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
|
||||
## Layout 规则
|
||||
|
||||
@@ -206,3 +206,9 @@
|
||||
- 数据点太密时标注互相遮挡(超过 10 个点考虑隔一个标注一次)
|
||||
- 折线段忘记设 endArrow: "none",默认带箭头
|
||||
- 多系列时折线颜色相近难以区分,应使用对比度高的不同色系
|
||||
|
||||
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `data` 数组,其余坐标与折线生成全自动计算。
|
||||
|
||||
```javascript
|
||||
const { writeFileSync } = require('fs');
|
||||
```
|
||||
@@ -16,12 +16,6 @@
|
||||
- 用户直接粘贴了 Mermaid 语法文本
|
||||
- 图表类型为思维导图、时序图、类图、饼图(自动路由)
|
||||
|
||||
## CLI 用法
|
||||
|
||||
```bash
|
||||
npx -y @larksuite/whiteboard-cli@^0.2.0 -i ./diagrams/{name}.mmd -o ./diagrams/{name}.png
|
||||
```
|
||||
|
||||
## 思维导图 (Mindmap)
|
||||
|
||||
```mermaid
|
||||
@@ -159,9 +159,7 @@
|
||||
{ "type": "connector", "connector": { "from": "child-b", "to": "leaf-b1", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } },
|
||||
{ "type": "connector", "connector": { "from": "child-b", "to": "leaf-b2", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } }
|
||||
]
|
||||
};
|
||||
|
||||
fs.writeFileSync('diagram.json', JSON.stringify(doc, null, 2));
|
||||
}
|
||||
```
|
||||
|
||||
## 陷阱
|
||||
@@ -29,7 +29,7 @@ vertical frame + 每层宽度递减。gap 4px 保持紧密。
|
||||
必须使用 `node` 运行脚本生成 JSON。
|
||||
|
||||
```javascript
|
||||
import fs from 'fs';
|
||||
const fs = require('fs');
|
||||
|
||||
// 1. 配置基础参数
|
||||
const GAP = 4;
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
1. **网格对齐是第一优先级**:跨泳道同一阶段必须严格对齐(水平对齐 x;垂直对齐 y)。对齐通过“共享阶段标尺(stage ruler / stage slots)”实现,不靠肉眼估算,也不靠逐节点随意手写坐标
|
||||
2. **只生成真实节点**:为保证跨泳道阶段严格对齐,所有阶段统一保留透明的 **stage cell**;仅在真实阶段的 cell 内生成卡片节点,并按阶段索引映射到对应槽位
|
||||
3. **泳道容器禁止使用背景色**:每条泳道是一个可见的分组容器,只能使用边框表达分组(建议统一使用浅灰色细虚线 `borderDash: "dashed"`,`borderWidth: 1`,`borderColor: "#DEE0E3"`),**不得使用任何有色背景作为泳道底色**,以降低视觉噪音。必须显式声明 `fillColor: "transparent"`,保持视觉透明
|
||||
3. **泳道底色**:为了增强层级感同时保持界面整洁,**强烈建议所有泳道容器统一使用极浅灰色背景**(如 `fillColor: "#F8F9FA"` 或 `"#FCFCFC"`)。边框使用浅灰色细虚线(`borderDash: "dashed"`, `borderWidth: 1`, `borderColor: "#DEE0E3"`)以明确边界。
|
||||
4. **步骤卡片**:使用 `rect`。为建立清晰的视觉层级,卡片**必须填充浅色背景**(参考 `references/style.md` 中的浅色板,如极浅的主题色),边框使用对应的主题主色(`borderWidth: 1-2`),文字使用深色(如 `#1F2329`)以确保可读性。统一圆角;宽高以可读为先,避免过窄导致换行过多
|
||||
5. **间距**:只要存在 connector 连线,卡片之间的主轴间距必须满足 `gap >= 40`;如果连线包含文字(`label`),主轴间距必须 `gap >= 64`,以提供充足的阅读空间。
|
||||
5. **间距**:只要存在 connector 连线,卡片之间的主轴间距必须满足 `gap >= 40`
|
||||
|
||||
### 子节点对齐
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
### 跨泳道间距(lanesGap)
|
||||
|
||||
- 根容器承载所有泳道:水平泳道用 `layout: "vertical"`,垂直泳道用 `layout: "horizontal"`
|
||||
- 固定跨泳道主轴间距 `lanesGap`(建议 `64-80`),更宽的间距能让跨泳道连线(特别是带文字时)有更多留白,降低重叠感
|
||||
- 缩减跨泳道主轴间距 `lanesGap`(建议 `16-24`),以保持整体图表的紧凑性。避免 `lanesGap` 设置为 `0` 导致边框重叠变粗,也避免间距过大导致视觉涣散。
|
||||
- 每条泳道作为根容器的子 frame,内部再使用上述 Flex 栅格的 stage cell 布局
|
||||
- `lanesGap` 与 `lanePadding/stackGap` 独立;lane 内容增减不应影响跨泳道间距
|
||||
- 4px 基线对齐:`lanesGap`、`lanePadding`、cell 尺寸建议按 4 的倍数对齐
|
||||
@@ -72,7 +72,7 @@
|
||||
- lane body:`layout: "vertical"`,包含完整的阶段 **stage cell** 数组;cell 高度固定为 `slotHeight`,相邻 cell 间 `gap` 统一;空阶段 cell 透明但保留
|
||||
- 内容居中对齐:stage cell 建议 `alignItems: "center"` + `justifyContent: "center"`,让卡片在每个 cell 内水平/垂直居中;卡片宽度不超过 `slotWidth`(或固定宽度),避免被 `"fill-container"` 拉伸导致“看起来不居中”
|
||||
- 步骤卡片:推荐统一卡片高度或统一 `slotHeight / gap`,保证跨泳道阶段严格 y 对齐
|
||||
- 泳道外层容器必须显式写 `fillColor: "transparent"`、`borderDash: "dashed"`、`borderWidth: 1`、`borderColor: "#DEE0E3"`(统一浅灰色),否则会被编译为虚拟 frame 导致不渲染
|
||||
- 泳道外层容器必须显式写 `fillColor: "#F8F9FA"`(极浅灰)、`borderDash: "dashed"`、`borderWidth: 1`、`borderColor: "#DEE0E3"`(统一浅灰色),否则会被编译为虚拟 frame 导致不渲染
|
||||
- 统一高度(Flex 自适应,可选):根容器使用 `alignItems: "stretch"`,每个泳道外层 frame 使用 `height: "fill-container"`;泳道内部仍保持 lane label + lane body 的结构
|
||||
|
||||
示例:
|
||||
@@ -86,7 +86,7 @@
|
||||
"id": "lanes-root",
|
||||
"x": 40, "y": 40,
|
||||
"layout": "horizontal",
|
||||
"gap": 64,
|
||||
"gap": 16,
|
||||
"alignItems": "stretch",
|
||||
"children": [
|
||||
{
|
||||
@@ -95,7 +95,7 @@
|
||||
"layout": "vertical",
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"fillColor": "#F8F9FA",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
@@ -106,7 +106,7 @@
|
||||
"textAlign": "center", "verticalAlign": "middle", "fontSize": 18, "fontWeight": "bold", "textColor": "#5178C6" }
|
||||
] },
|
||||
{ "type": "frame", "id": "lane-left-body", "layout": "vertical",
|
||||
"gap": 64, "padding": 16,
|
||||
"gap": 40, "padding": 16,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "stage-1-cell-left", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center",
|
||||
"children": [{ "type": "rect", "id": "c-s1", "width": 200, "height": "fit-content", "fillColor": "#E1EAFA", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8 }] },
|
||||
@@ -120,7 +120,7 @@
|
||||
"layout": "vertical",
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"fillColor": "#F8F9FA",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
@@ -131,7 +131,7 @@
|
||||
"textAlign": "center", "verticalAlign": "middle", "fontSize": 18, "fontWeight": "bold", "textColor": "#8569CB" }
|
||||
] },
|
||||
{ "type": "frame", "id": "lane-right-body", "layout": "vertical",
|
||||
"gap": 64, "padding": 16,
|
||||
"gap": 40, "padding": 16,
|
||||
"children": [
|
||||
{ "type": "frame", "id": "stage-1-cell-right", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center", "children": [] },
|
||||
{ "type": "frame", "id": "stage-2-cell-right", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center",
|
||||
@@ -142,13 +142,14 @@
|
||||
]
|
||||
},
|
||||
{ "type": "connector", "connector": { "from": "c-s1", "to": "d-s2",
|
||||
"lineShape": "polyline", "lineColor": "#BBBFC4", "lineWidth": 2, "endArrow": "arrow" } }
|
||||
"lineShape": "polyline", "lineColor": "#BBBFC4", "lineWidth": 2, "endArrow": "arrow" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 泳道配色(默认色板)
|
||||
|
||||
- **泳道背景**:所有泳道容器统一使用极浅灰色(如 `fillColor: "#F8F9FA"` 或 `"#FCFCFC"`),以增强物理容器的层级感,并突出内部的彩色卡片。
|
||||
- **泳道边框**:所有泳道外层容器统一使用浅灰色细虚线(`borderColor: "#DEE0E3"`, `borderWidth: 1`, `borderDash: "dashed"`)。
|
||||
- **泳道标题**:按 `references/style.md` 经典色板为每条泳道分配不同的主题色,泳道 title 的 `textColor` 使用该主题色。
|
||||
- **内容节点(rect)**:采用“浅色底 + 主题色边框”策略。`fillColor` 使用与该泳道主题色对应的极浅色(如浅蓝、浅紫等),`borderColor` 使用对应的主题色,文字 `textColor` 统一使用深色 `#1F2329`。
|
||||
@@ -188,7 +189,7 @@
|
||||
"x": 40,
|
||||
"y": 40,
|
||||
"layout": "vertical",
|
||||
"gap": 64,
|
||||
"gap": 16,
|
||||
"alignItems": "stretch",
|
||||
"padding": 0,
|
||||
"width": "fit-content",
|
||||
@@ -198,11 +199,11 @@
|
||||
"type": "frame",
|
||||
"id": "lane-a",
|
||||
"layout": "horizontal",
|
||||
"gap": 64,
|
||||
"gap": 40,
|
||||
"padding": 16,
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"fillColor": "#F8F9FA",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
@@ -267,11 +268,11 @@
|
||||
"type": "frame",
|
||||
"id": "lane-b",
|
||||
"layout": "horizontal",
|
||||
"gap": 64,
|
||||
"gap": 40,
|
||||
"padding": 16,
|
||||
"width": "fit-content",
|
||||
"height": "fill-container",
|
||||
"fillColor": "transparent",
|
||||
"fillColor": "#F8F9FA",
|
||||
"borderDash": "dashed",
|
||||
"borderWidth": 1,
|
||||
"borderColor": "#DEE0E3",
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## Layout 选型
|
||||
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .js 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.0` 渲染
|
||||
- 不适合手动心算坐标
|
||||
|
||||
## Layout 规则
|
||||
@@ -208,3 +208,9 @@
|
||||
- **分类标签不可见**:分类标签 text 节点必须在其子矩形 rect 节点之前添加(z-index 靠后的节点在上层)
|
||||
- **面积比例不正确**:必须用脚本预先计算比例,不要心算
|
||||
- **缺少配色区分**:不同顶层分类必须用不同背景色(从色板选取),所有子节点继承对应色系
|
||||
|
||||
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `data` 树,其余坐标与矩形面积自动递归计算。
|
||||
|
||||
```javascript
|
||||
const { writeFileSync } = require('fs');
|
||||
```
|
||||
@@ -35,6 +35,13 @@ metadata:
|
||||
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut,需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`。
|
||||
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`。
|
||||
|
||||
## 目标语义约束
|
||||
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -44,13 +51,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)
|
||||
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
|
||||
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
|
||||
|
||||
## 目标语义约束
|
||||
|
||||
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
|
||||
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作
|
||||
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
|
||||
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -62,6 +62,7 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
|
||||
### spaces
|
||||
|
||||
- `create` — 创建知识空间
|
||||
- `get` — 获取知识空间信息
|
||||
- `get_node` — 获取知识空间节点信息
|
||||
- `list` — 获取知识空间列表
|
||||
@@ -82,6 +83,7 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `spaces.create` | `wiki:space:write_only` |
|
||||
| `spaces.get` | `wiki:space:read` |
|
||||
| `spaces.get_node` | `wiki:node:read` |
|
||||
| `spaces.list` | `wiki:space:retrieve` |
|
||||
@@ -91,3 +93,4 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
| `nodes.copy` | `wiki:node:copy` |
|
||||
| `nodes.create` | `wiki:node:create` |
|
||||
| `nodes.list` | `wiki:node:retrieve` |
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
|
||||
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
|
||||
baseToken := createBaseWithRetry(t, ctx, baseName)
|
||||
|
||||
t.Run("get base", func(t *testing.T) {
|
||||
t.Run("get base as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+base-get", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
@@ -49,7 +49,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
|
||||
`{"name":"Main","type":"grid"}`,
|
||||
)
|
||||
|
||||
t.Run("get table", func(t *testing.T) {
|
||||
t.Run("get table as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
|
||||
DefaultAs: "bot",
|
||||
@@ -61,7 +61,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
|
||||
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
|
||||
})
|
||||
|
||||
t.Run("list tables and find created table", func(t *testing.T) {
|
||||
t.Run("list tables and find created table as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-list", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestBase_RoleWorkflow(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
t.Run("list as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-list", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
@@ -81,7 +81,7 @@ func TestBase_RoleWorkflow(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
t.Run("get role as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "role ID should be resolved before get")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
@@ -98,7 +98,7 @@ func TestBase_RoleWorkflow(t *testing.T) {
|
||||
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
|
||||
})
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
t.Run("update role as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "role ID should be resolved before update")
|
||||
|
||||
updatedRoleName := roleName + " Updated"
|
||||
|
||||
91
tests/cli_e2e/base/coverage.md
Normal file
91
tests/cli_e2e/base/coverage.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Base CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 73 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 13.7%
|
||||
|
||||
## Summary
|
||||
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✕ | base +advperm-disable | shortcut | | none | no disable workflow yet |
|
||||
| ✓ | base +advperm-enable | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow | `--base-token` | |
|
||||
| ✕ | base +base-copy | shortcut | | none | no copy workflow yet |
|
||||
| ✓ | base +base-create | shortcut | base/helpers_test.go::createBaseWithRetry | `--name`; `--time-zone` | helper asserts created base token |
|
||||
| ✓ | base +base-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get base as bot | `--base-token` | |
|
||||
| ✕ | base +dashboard-arrange | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-delete | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-get | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-list | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +dashboard-update | shortcut | | none | dashboard workflows not covered |
|
||||
| ✕ | base +data-query | shortcut | | none | no data-query assertions yet |
|
||||
| ✕ | base +field-create | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-delete | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-get | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-list | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-search-options | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
|
||||
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-create | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +record-batch-create | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-batch-update | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-get | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-upload-attachment | shortcut | | none | record workflows not covered |
|
||||
| ✕ | base +record-upsert | shortcut | | none | record workflows not covered |
|
||||
| ✓ | base +role-create | shortcut | base/helpers_test.go::createRole | `--base-token`; `--json` | helper asserts created role id |
|
||||
| ✕ | base +role-delete | shortcut | | none | cleanup only |
|
||||
| ✓ | base +role-get | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/get as bot | `--base-token`; `--role-id` | |
|
||||
| ✓ | base +role-list | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/list as bot | `--base-token` | |
|
||||
| ✓ | base +role-update | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/update as bot | `--base-token`; `--role-id`; `--json` | |
|
||||
| ✓ | base +table-create | shortcut | base/helpers_test.go::createTableWithRetry | `--base-token`; `--name`; optional `--fields`; optional `--view` | helper asserts table id |
|
||||
| ✕ | base +table-delete | shortcut | | none | cleanup only |
|
||||
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
|
||||
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
|
||||
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
|
||||
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get-card | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get-filter | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get-group | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get-sort | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get-timebar | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-get-visible-fields | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-list | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-rename | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-set-card | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-set-filter | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-set-group | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-set-sort | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-set-timebar | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +view-set-visible-fields | shortcut | | none | view workflows not covered |
|
||||
| ✕ | base +workflow-create | shortcut | | none | workflow CRUD not covered |
|
||||
| ✕ | base +workflow-disable | shortcut | | none | workflow CRUD not covered |
|
||||
| ✕ | base +workflow-enable | shortcut | | none | workflow CRUD not covered |
|
||||
| ✕ | base +workflow-get | shortcut | | none | workflow CRUD not covered |
|
||||
| ✕ | base +workflow-list | shortcut | | none | workflow CRUD not covered |
|
||||
| ✕ | base +workflow-update | shortcut | | none | workflow CRUD not covered |
|
||||
@@ -31,7 +31,7 @@ func TestCalendar_CreateEvent(t *testing.T) {
|
||||
var eventID string
|
||||
calendarID := getPrimaryCalendarID(t, ctx)
|
||||
|
||||
t.Run("create event with shortcut", func(t *testing.T) {
|
||||
t.Run("create event with shortcut as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "+create",
|
||||
"--summary", eventSummary,
|
||||
@@ -50,7 +50,7 @@ func TestCalendar_CreateEvent(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
})
|
||||
|
||||
t.Run("verify event created", func(t *testing.T) {
|
||||
t.Run("verify event created as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "get"},
|
||||
@@ -69,7 +69,7 @@ func TestCalendar_CreateEvent(t *testing.T) {
|
||||
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
|
||||
})
|
||||
|
||||
t.Run("delete event", func(t *testing.T) {
|
||||
t.Run("delete event as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "delete"},
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
|
||||
var createdCalendarID string
|
||||
|
||||
t.Run("list calendars", func(t *testing.T) {
|
||||
t.Run("list calendars as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "list"},
|
||||
DefaultAs: "bot",
|
||||
@@ -37,12 +37,12 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("get primary calendar", func(t *testing.T) {
|
||||
t.Run("get primary calendar as bot", func(t *testing.T) {
|
||||
primaryCalendarID := getPrimaryCalendarID(t, ctx)
|
||||
require.NotEmpty(t, primaryCalendarID)
|
||||
})
|
||||
|
||||
t.Run("create calendar", func(t *testing.T) {
|
||||
t.Run("create calendar as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "create"},
|
||||
DefaultAs: "bot",
|
||||
@@ -59,7 +59,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
})
|
||||
|
||||
t.Run("get created calendar", func(t *testing.T) {
|
||||
t.Run("get created calendar as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "get"},
|
||||
@@ -76,7 +76,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
|
||||
})
|
||||
|
||||
t.Run("find created calendar in list", func(t *testing.T) {
|
||||
t.Run("find created calendar in list as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "list"},
|
||||
@@ -88,7 +88,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("update calendar", func(t *testing.T) {
|
||||
t.Run("update calendar as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "patch"},
|
||||
@@ -105,7 +105,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("verify updated calendar", func(t *testing.T) {
|
||||
t.Run("verify updated calendar as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "get"},
|
||||
@@ -120,7 +120,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
|
||||
})
|
||||
|
||||
t.Run("delete calendar", func(t *testing.T) {
|
||||
t.Run("delete calendar as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "delete"},
|
||||
|
||||
134
tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go
Normal file
134
tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCalendar_PersonalEventWorkflowAsUser(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
eventSummary := "lark-cli-e2e-personal-event-" + suffix
|
||||
eventDescription := "created by calendar personal event workflow"
|
||||
startAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute)
|
||||
endAt := startAt.Add(30 * time.Minute)
|
||||
startTime := startAt.Format(time.RFC3339)
|
||||
endTime := endAt.Format(time.RFC3339)
|
||||
|
||||
var calendarID string
|
||||
var eventID string
|
||||
|
||||
t.Run("get primary calendar as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "primary"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
calendarID = gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
|
||||
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("create personal event with shortcut as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, calendarID, "calendar should be loaded before creating an event")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+create",
|
||||
"--summary", eventSummary,
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
"--calendar-id", calendarID,
|
||||
"--description", eventDescription,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
eventID = gjson.Get(result.Stdout, "data.event_id").String()
|
||||
require.NotEmpty(t, eventID, "stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "delete"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get created event as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, calendarID, "calendar should be loaded before getting an event")
|
||||
require.NotEmpty(t, eventID, "event should be created before reading it back")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "get"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, eventID, gjson.Get(result.Stdout, "data.event.event_id").String())
|
||||
assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String())
|
||||
assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String())
|
||||
assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
|
||||
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
|
||||
})
|
||||
|
||||
t.Run("find created event in agenda as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID, "event should be created before checking agenda")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+agenda",
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
matchedEvent := gjson.Get(result.Stdout, `data.#(event_id=="`+eventID+`")`)
|
||||
require.True(t, matchedEvent.Exists(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, eventSummary, matchedEvent.Get("summary").String())
|
||||
|
||||
agendaStart, parseErr := time.Parse(time.RFC3339, matchedEvent.Get("start_time.datetime").String())
|
||||
require.NoError(t, parseErr, "stdout:\n%s", result.Stdout)
|
||||
agendaEnd, parseErr := time.Parse(time.RFC3339, matchedEvent.Get("end_time.datetime").String())
|
||||
require.NoError(t, parseErr, "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, agendaStart.Equal(startAt), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, agendaEnd.Equal(endAt), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
214
tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go
Normal file
214
tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func requireFreebusyEntry(t *testing.T, stdout string, startAt time.Time, endAt time.Time, expectedRSVP string) {
|
||||
t.Helper()
|
||||
|
||||
var matched gjson.Result
|
||||
for _, item := range gjson.Parse(stdout).Get("data").Array() {
|
||||
itemStart, err := time.Parse(time.RFC3339, item.Get("start_time").String())
|
||||
require.NoError(t, err, "stdout:\n%s", stdout)
|
||||
itemEnd, err := time.Parse(time.RFC3339, item.Get("end_time").String())
|
||||
require.NoError(t, err, "stdout:\n%s", stdout)
|
||||
|
||||
if !itemStart.Equal(startAt) || !itemEnd.Equal(endAt) {
|
||||
continue
|
||||
}
|
||||
if item.Get("rsvp_status").String() != expectedRSVP {
|
||||
continue
|
||||
}
|
||||
matched = item
|
||||
break
|
||||
}
|
||||
|
||||
require.True(t, matched.Exists(), "expected freebusy entry start=%s end=%s rsvp=%s in stdout:\n%s", startAt.Format(time.RFC3339), endAt.Format(time.RFC3339), expectedRSVP, stdout)
|
||||
assert.Equal(t, expectedRSVP, matched.Get("rsvp_status").String(), "stdout:\n%s", stdout)
|
||||
}
|
||||
|
||||
func TestCalendar_RSVPWorkflowAsUser(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
userOpenID := getCurrentUserOpenIDForCalendar(t, ctx)
|
||||
calendarID := getPrimaryCalendarID(t, ctx)
|
||||
startAt := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Minute)
|
||||
endAt := startAt.Add(30 * time.Minute)
|
||||
startTime := startAt.Format(time.RFC3339)
|
||||
endTime := endAt.Format(time.RFC3339)
|
||||
var eventID string
|
||||
|
||||
t.Run("query freebusy as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+freebusy",
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
data := gjson.Get(result.Stdout, "data")
|
||||
require.True(t, data.IsArray() || data.Type == gjson.Null, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("create invite-only event as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+create",
|
||||
"--summary", "lark-cli-e2e-calendar-rsvp-" + clie2e.GenerateSuffix(),
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
"--calendar-id", calendarID,
|
||||
"--attendee-ids", userOpenID,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
eventID = gjson.Get(result.Stdout, "data.event_id").String()
|
||||
require.NotEmpty(t, eventID, "stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("reply tentative as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID, "event should be created before RSVP")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+rsvp",
|
||||
"--calendar-id", calendarID,
|
||||
"--event-id", eventID,
|
||||
"--rsvp-status", "tentative",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, calendarID, gjson.Get(result.Stdout, "data.calendar_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, eventID, gjson.Get(result.Stdout, "data.event_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "tentative", gjson.Get(result.Stdout, "data.rsvp_status").String())
|
||||
})
|
||||
|
||||
t.Run("verify tentative freebusy as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+freebusy",
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 || !gjson.Get(result.Stdout, "status").Bool() {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.Parse(result.Stdout).Get("data").Array() {
|
||||
itemStart, err := time.Parse(time.RFC3339, item.Get("start_time").String())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
itemEnd, err := time.Parse(time.RFC3339, item.Get("end_time").String())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if itemStart.Equal(startAt) && itemEnd.Equal(endAt) && item.Get("rsvp_status").String() == "tentative" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
requireFreebusyEntry(t, result.Stdout, startAt, endAt, "tentative")
|
||||
})
|
||||
|
||||
t.Run("reply accept as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+rsvp",
|
||||
"--calendar-id", calendarID,
|
||||
"--event-id", eventID,
|
||||
"--rsvp-status", "accept",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, calendarID, gjson.Get(result.Stdout, "data.calendar_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, eventID, gjson.Get(result.Stdout, "data.event_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "accept", gjson.Get(result.Stdout, "data.rsvp_status").String())
|
||||
})
|
||||
|
||||
t.Run("verify accepted freebusy as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+freebusy",
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 || !gjson.Get(result.Stdout, "status").Bool() {
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.Parse(result.Stdout).Get("data").Array() {
|
||||
itemStart, err := time.Parse(time.RFC3339, item.Get("start_time").String())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
itemEnd, err := time.Parse(time.RFC3339, item.Get("end_time").String())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if itemStart.Equal(startAt) && itemEnd.Equal(endAt) && item.Get("rsvp_status").String() == "accept" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
requireFreebusyEntry(t, result.Stdout, startAt, endAt, "accept")
|
||||
})
|
||||
}
|
||||
57
tests/cli_e2e/calendar/calendar_view_agenda_test.go
Normal file
57
tests/cli_e2e/calendar/calendar_view_agenda_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCalendar_ViewAgenda(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
calendarID := getCurrentUserPrimaryCalendarID(t, ctx)
|
||||
|
||||
t.Run("view today agenda as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "+agenda", "--calendar-id", calendarID},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.True(t, gjson.Get(result.Stdout, "data").IsArray(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("view agenda with date range as user", func(t *testing.T) {
|
||||
startDate := time.Now().UTC().Format("2006-01-02")
|
||||
endDate := time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "+agenda", "--calendar-id", calendarID, "--start", startDate, "--end", endDate},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.True(t, gjson.Get(result.Stdout, "data").IsArray(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("view agenda with pretty format as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "+agenda"},
|
||||
DefaultAs: "user",
|
||||
Format: "pretty",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user