Compare commits

..

17 Commits

Author SHA1 Message Date
syh-cpdsss
09e60eeaf4 fix: add OKR API restriction in SKILL.md (#546)
Change-Id: Ic2734f1da8525ec48f091ccd72c96921b9bb0fc1
2026-04-17 22:36:50 +08:00
liangshuo-1
4f90fd3b77 chore: cut v1.0.14 (#544)
Change-Id: I601e893a048a155635ecd75d5c433b99c42e55fe
2026-04-17 21:46:09 +08:00
chanthuang
6212513c43 feat(mail): add email priority support for compose and read (#538)
* feat(mail): add email priority support for compose and read

Write: all compose shortcuts (+send, +reply, +reply-all, +forward,
+draft-create) accept --priority (high/normal/low) which sets the
X-Cli-Priority EML header. +draft-edit accepts --set-priority.

Read: normalizeMessage now infers priority from label_ids
(HIGH_PRIORITY/LOW_PRIORITY), with priority_type as fallback.

Change-Id: Ib5bc4e99331c6ce0d3850865825fcd1ff2183f0c
Co-Authored-By: AI

* docs(mail): add --priority and --set-priority to skill references

Update 6 skill reference docs: +send, +reply, +reply-all, +forward,
+draft-create add --priority param; +draft-edit adds --set-priority.

Change-Id: I75d13fbf6a5ca4dfbf76e84fe39e4ee55b689751
Co-Authored-By: AI

* test(mail): add unit and integration tests for --priority

- helpers_test.go: cover parsePriority (valid/invalid/case/whitespace)
  and applyPriority (empty vs non-empty) end-to-end via EML builder
- mail_draft_create_test.go: verify --priority propagates to X-Cli-Priority
  header in the built EML, and no header when priority is empty

Change-Id: I62ca96b3e296b5898798cfa681f5efd4f101cb40
Co-Authored-By: AI

* test(mail): cover buildDraftEditPatch --set-priority and label-based priority

- helpers_test.go: TestBuildMessageOutput_PriorityFromLabels verifies
  HIGH_PRIORITY/LOW_PRIORITY labels map to priority_type_text, and that
  label values override the priority_type fallback field
- mail_draft_edit_test.go (new): cover --set-priority high/low/normal
  (set_header vs remove_header), invalid value rejection, and absence
  of priority op when the flag is unused

Change-Id: Idd5ace2fb812cf3eb329c79eeab3c8b9808fcf0b
Co-Authored-By: AI

* fix(mail): write priority_type to output when inferred from label_ids

buildMessageOutput only wrote priority_type_text but not priority_type
when priority was inferred from HIGH_PRIORITY/LOW_PRIORITY labels.
Also covers the case where label overrides an explicit priority_type field.

Change-Id: I7879976d21235b8006b5c8ebe6a413e2815354e1

* fix(mail): validate --priority in Validate so invalid values fail before dry-run/Execute

Change-Id: Ic277ab683967c47f28c892d3512b0ab745bd86f6

* test(mail): add TestValidatePriorityFlag to cover invalid --priority rejected in Validate

Change-Id: I7f12c0a0b0d15c491c28fdcb8729f2f648ba0244
2026-04-17 20:49:32 +08:00
caojie0621
e8df0ea63e feat(drive): support sheet cell comments in +add-comment (#518)
Extend +add-comment to accept sheet URLs and wiki URLs that resolve
to sheets. Reuse --block-id with <sheetId>!<cell> format (e.g.
a281f9!D6) for sheet cell positioning.

Wiki links resolving to sheet type are handled by first calling
get_node, then redirecting to the sheet comment path with proper
parameter validation.
2026-04-17 20:05:08 +08:00
nickzhang
6d0d687be2 feat(doc): add --file-view flag to +media-insert (#419)
* feat(doc): add --file-view flag to +media-insert for file block rendering

The docx File block supports three render modes via view_type
(1=card, 2=preview inline player, 3=inline), but --type=file today
always creates with the default card view. Because view_type can only
be set at creation time (PATCH replace_file ignores it), callers
wanting an inline audio/video player had to abandon the shortcut and
reimplement the full 4-step orchestration manually.

Add --file-view card|preview|inline that threads into file.view_type
on block creation. Omitting the flag preserves the exact request body
that the shortcut sends today, so existing users are unaffected.

--file-view is rejected when combined with --type=image (images have
their own rendering) and when an unknown value is passed.

* refactor(doc): narrow view_type gate and relax file-view test

Address review feedback from automated reviewers on #419:

- Replace `fileViewType > 0` with an explicit 1|2|3 whitelist inside
  buildCreateBlockData so a stray positive int cannot escape into the
  request payload if a future caller bypasses Validate.
- Relax TestFileViewMapCoversDocumentedValues to assert only the
  documented keys rather than full-map equality, so future aliases
  (e.g. a "player" synonym for preview) do not falsely break the test.

No behaviour change for any existing --file-view input.

* test(doc): cover --file-view Validate contract and explicit card path

Pins down the two CLI guard branches (unknown --file-view value and
--file-view passed with --type!=file) that were previously only covered
indirectly through buildCreateBlockData. Also adds the --file-view card
case so the explicit view_type=1 payload (different from the legacy
file: {} shape when the flag is omitted) is locked in as part of the
public flag contract.

* fix: repair unit tests

Change-Id: I8c6bb69bfa22c9455a2cbb0f46b401e2cbe87762

---------

Co-authored-by: Nick Zhang <nickzhangcomes@users.noreply.github.com>
Co-authored-by: wangweiming <wangweiming@bytedance.com>
2026-04-17 19:27:00 +08:00
syh-cpdsss
148a04a7f8 Feat: Add OKR business domain (#522)
* feat: okr domain

Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b

* feat: okr skill update

Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b
2026-04-17 18:04:15 +08:00
liujinkun2025
ba19bd9f93 feat(wiki): improve wiki skill docs and add wiki domain template (#512)
- Reorder sections, fix formatting and indentation in SKILL.md
- Add spaces.create method and its scope to API resources and permission table
- Add wiki domain template for skill-template

Change-Id: Ib03dacc02cf2b42f807615c2adedbf79694b5dc0
2026-04-17 18:03:21 +08:00
zkh-bytedance
830fb3bbe5 refactor(skills): introduce lark-doc-whiteboard.md and streamline whiteboard workflow (#502)
- add lark-doc/references/lark-doc-whiteboard.md: defines role boundaries
  between lark-doc and lark-whiteboard, step-by-step doc↔whiteboard
  coordination flow, and semantic-to-chart-type mapping table
- lark-doc-create.md: tighten post-create whiteboard flow (step 2 now
  directly references the "渲染 & 写入画板" section); strengthen 主动画板
  guideline with explicit placeholder syntax, prohibition on PNG/SVG
  substitution, and concrete routing examples
- lark-whiteboard/SKILL.md: upgrade to v0.2, rewrite with structured
  quick-decision table, creation/modification workflows, render routing
  table, and dry-run write guard
- extract rendering routes into routes/{dsl,mermaid,svg}.md; add
  per-chart scene guides under scenes/
- remove lark-whiteboard-cli/SKILL.md (absorbed into lark-whiteboard)
2026-04-17 17:51:50 +08:00
Yuxuan Zhao
1ad7cfab5b test: inject user env only for cli e2e user commands (#541) 2026-04-17 17:33:37 +08:00
Yuxuan Zhao
5280517d4b Feat/cli e2e tests with UAT (#528)
* test: expand and stabilize cli e2e workflows

* ci: run deadcode with test entrypoints
2026-04-17 16:57:17 +08:00
JackZhao10086
3ad6f2fac4 Revert "Add client_secret to device authorization request (#517)" (#539)
This reverts commit 663c24aadf.
2026-04-17 16:29:04 +08:00
feng zhi hao
be79485fe3 feat: mail support scheduled send (#534)
feat: mail support scheduled send (#534)
2026-04-17 15:41:42 +08:00
zgz2048
94bba91224 feat(base): auto grant current user for bot create and copy (#497)
* feat(base): auto grant current user for bot create and copy

* fix(base): declare auto-grant permission scope

* Apply suggestion from @kongenpei

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>

* Apply suggestion from @kongenpei

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>

* style(base): format auth-specific scope declarations

* fix(base): use bitable permission target for auto-grant

---------

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>
2026-04-17 14:30:47 +08:00
wangqiucheng2bd
0d50616e77 feat(base): add identity priority strategy and error handling (#505)
* feat(base): add identity priority strategy and 91403 error handling

Establish user-first identity selection with graceful degradation to bot,
and add no-retry rule for error code 91403 (permission denied on Base).

* fix(base): add 91403 early-exit before identity fallback logic

Move non-retryable error code check (e.g. 91403) to a dedicated step
before the user/bot fallback decision, resolving conflicting instructions
between the error table and the execution rules.


* Update SKILL.md

* Update SKILL.md

---------
2026-04-17 13:46:05 +08:00
JackZhao10086
d5784eac28 feat(auth): improve login scope handling and messages (#523)
* feat(auth): improve login scope handling and messages

- Add AuthorizedUser message to display current authorized account
- Update scope mismatch message wording to be more accurate
- Reorganize login success output to show scope issues first
- Remove redundant success message when scope issues exist

* fix(auth): update login success message wording from "login" to "authorization"

Update both Chinese and English login success messages to use "authorization" instead of "login" for consistency with the authentication flow. Also update corresponding test cases to match the new wording.

* test(auth): update login test for missing scope case

Update test assertions to verify correct error messages when requested scopes are not granted. Remove checks for success message in this scenario.
2026-04-17 12:16:29 +08:00
Uğur Tafralı
663c24aadf Add client_secret to device authorization request (#517) 2026-04-17 11:39:23 +08:00
zero-my
6ad25cd452 docs(task): document custom_fields and custom_field_options API resources and permissions (#524) 2026-04-16 23:40:55 +08:00
146 changed files with 8815 additions and 1296 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -38,6 +38,7 @@
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐和指标 |
## 安装与快速开始

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)",

View File

@@ -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 目标、关键结果、对齐、量化指标" }
}
}

View File

@@ -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"

View File

@@ -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),

View File

@@ -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},

View File

@@ -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) {

View File

@@ -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 ""
}

View File

@@ -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{}{}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)")
}

View 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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View 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)
}
}

View 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"`
}

View 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
},
}

View 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")
}
}

View 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
},
}

View 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")
}
}

View 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
}

View 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 }

View 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,
}
}

View 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)
})
}

View File

@@ -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).

View File

@@ -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 小时内已投递的邮件)。

View 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 域处理

View File

@@ -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` 权限不足处理流程引导用户解决权限问题 |

View File

@@ -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 转给自己,必须单独确认。

View File

@@ -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 转给自己,必须单独确认。

View File

@@ -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. |

View File

@@ -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

View File

@@ -1,6 +0,0 @@
# docs +whiteboard-update更新飞书画板
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 shortcut 仅为兼容历史调用保留,具体使用方式请参考 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md)

View 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)

View File

@@ -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。

View File

@@ -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` |

View File

@@ -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` | 否 | 仅打印请求,不执行 |

View File

@@ -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` 的草稿投影 |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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` |

View 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": "进行用户测试"
}
}
]
}
}
]
}
```

View 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 &lt;id&gt;` | 是 | — | OKR 周期 IDint64 类型)。从 `+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) -- 认证和全局参数

View 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 &lt;id&gt;` | 是 | — | OKR 所有者的用户 ID |
| `--user-id-type &lt;type&gt;` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--time-range &lt;range&gt;` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
| `--format &lt;fmt&gt;` | 否 | `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) -- 认证和全局参数

View 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目标值100unit 参见下文设置为 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) — 获取周期下的目标与关键结果

View File

@@ -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` |

View File

@@ -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 ← DSLCLI 输入)
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-contentconnector 在顶层 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 放置子节点。

View File

@@ -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 JSONSVG 路径从 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`。

View File

@@ -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#渲染--写入画板)。
## 示例

View File

@@ -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

View File

@@ -58,7 +58,7 @@
| ---------------------- | ----------------------------------------------------------------------------- |
| 纯 Flex / Dagre | 直接写 JSON |
| 混合布局 (Flex包Dagre) | 直接写 JSON外层先做分区局部复杂关系交给 Dagre若被嵌套默认为不透明节点 |
| 极度依赖几何坐标的图 | 写脚本生成 JSONnode xxx.js |
| 极度依赖几何坐标的图 | 写脚本生成 JSONnode xxx.cjs |
| 需要精确避让的特殊线 | 脚本 + `--layout` 两阶段 |
---

View 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-contentconnector 在顶层 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 }`

View 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 写入成功
```

View 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>` → 画板通过图片路径保留视觉 (光晕/阴影/纹理/遮罩等效果都在, 元素不可再编辑但不丢视觉)

View File

@@ -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');
```

View File

@@ -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 规则

View File

@@ -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');

View File

@@ -26,10 +26,10 @@
## 脚本构建模板
必须使`node` 运行脚本生成 JSON
此场景必须用 .cjs 脚本生成
```javascript
import fs from 'fs';
const fs = require('fs');
// 1. 配置基础参数
const GAP = 4;

View File

@@ -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');
```

View File

@@ -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

View File

@@ -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));
}
```
## 陷阱

View File

@@ -29,7 +29,7 @@ vertical frame + 每层宽度递减。gap 4px 保持紧密。
必须使用 `node` 运行脚本生成 JSON。
```javascript
import fs from 'fs';
const fs = require('fs');
// 1. 配置基础参数
const GAP = 4;

View File

@@ -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",

View File

@@ -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');
```

View File

@@ -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` |

0
span.log Normal file
View File

View File

@@ -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",

View File

@@ -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"

View 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 |

View File

@@ -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"},

View File

@@ -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"},

View 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)
})
}

View 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")
})
}

View 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