Compare commits

..

27 Commits

Author SHA1 Message Date
liangshuo-1
0a0cdc8879 chore(release): v1.0.15 (#575) 2026-04-20 22:03:08 +08:00
zhengquanbin
67e51ec8d7 fix: base role view & record default perm on edit(#530)
fix: address coderabbit review comments on role-config docs

- Update `allow_edit` field description to reflect conditional default:
  `true` when table perm is `edit`, `false` for `read_only` or explicit restriction
- Move `record_operations.delete` out of "默认关闭项" into new "默认开启项(条件性)"
  section to accurately reflect it is default-included when `perm = edit`
- Add `view_rule.allow_edit` to "默认开启项(条件性)" section with same logic

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:08:04 +08:00
sang-neo03
5943a20e2b Feat/auth sidecar proxy (#532)
* feat(sidecar): add sidecar proxy for sandbox credential isolation

Keep real secrets (app_secret, access_token) out of sandbox environments.
CLI instances inside sandboxes connect to a trusted sidecar process via
HTTP; the sidecar verifies HMAC-signed requests and injects real tokens
before forwarding to the Lark API.

Key components:

- `auth proxy` subcommand to start the sidecar server (build tag: authsidecar)
- Noop credential provider returns sentinel tokens in sidecar mode
- Transport interceptor rewrites requests to sidecar with HMAC signature
- Env provider yields to sidecar provider when AUTH_PROXY is set
- Supports both feishu and lark brand endpoints

* feat(sidecar): implement priority ordering for credential providers

* feat(sidecar): strip client-supplied auth headers and improve shutdown logging

* feat(sidecar): buffer request body to prevent HMAC mismatches on read errors

* feat(sidecar): fix CI

* refactor(sidecar): publish protocol package and move server to reference demo

  The sidecar server is no longer shipped as a `lark-cli auth proxy`
  subcommand. Instead, the CLI provides only the standard sidecar *client*
  (via `-tags authsidecar`), while the wire-protocol utilities are exposed
  as a public package for integrators to implement their own server.

  Changes:
  - Move `internal/sidecar/` → `sidecar/` so external integrators can
    import HMAC signing, headers, sentinels and address validators.
  - Remove `cmd/auth/proxy.go`, `proxy_stub.go`, `proxy_test.go` and the
    conditional registration in `cmd/auth/auth.go`.
  - Add `sidecar/server-demo/` — a reference server implementation behind
    the `authsidecar_demo` build tag. It reuses the lark-cli credential
    pipeline for local development; production integrators are expected
    to replace the credential layer with their own secrets source.
  - Update all internal imports from `internal/sidecar` to `sidecar`.

  Rationale:
  - Each integrator has different secrets management / HA / multi-tenant
    requirements, so a one-size-fits-all server doesn't belong in the
    shipped CLI.
  - Keeping the client in-tree guarantees all sandbox-side code stays
    protocol-compatible without a second repo to sync.
  - The public `sidecar/` package pins the wire protocol as a stable
    contract third-party servers must conform to.

  Build matrix after this change:
  - `go build`                         → standard CLI, no sidecar code
  - `go build -tags authsidecar`       → CLI + sidecar client
  - `go build -tags authsidecar_demo \
      ./sidecar/server-demo/`          → reference server binary

  No production users are affected today because the server was not yet
  released; existing sidecar-client users are unchanged.

* feat(sidecar): close 5 pre-release security gaps
  - Server: enforce https-only target (no path/query/userinfo), pin
    forwardURL to https:// — blocks cleartext token leak
  - Protocol v1: canonical now covers version/identity/auth-header,
    blocks identity-flip replay within drift window
  - Client: ValidateProxyAddr requires loopback or same-host alias,
    rejects userinfo and https (interceptor is http-only); cross-machine
    is out of scope
  - Build: non-authsidecar builds exit(2) when AUTH_PROXY is set,
    preventing silent fallback to env credentials
  - Demo: whitelist auth-header to Authorization / X-Lark-MCP-{UAT,TAT},
    blocks token injection into Cookie / UA / X-Forwarded-For exfil paths
2026-04-20 20:24:51 +08:00
kongenpei
cd666422ac fix(base): preserve attachment metadata on base uploads (#563)
* fix: preserve attachment metadata on base uploads

* test: cover attachment mime detection

* fix: address attachment upload review feedback

* fix: preserve source extension for attachment mime detection

* fix: avoid registry test refresh data race

* Revert "fix: avoid registry test refresh data race"

This reverts commit c1d12d0cf1.

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-20 19:14:45 +08:00
mazhe-nerd
9acd121259 fix: update install message (#529) 2026-04-20 12:03:16 +08:00
caojie0621
1262aac480 fix(sheets): normalize single-cell range in +set-style and +batch-set-style (#548)
/style and /styles_batch_update require full "A1:A1" form and reject
single-cell shorthand "A1". +set-style was using normalizeSheetRange
(prefix-only) and +batch-set-style passed --data through unchanged,
so both failed with `wrong range` when callers supplied a single cell.

Switch +set-style to normalizePointRange, and walk each ranges[]
entry in +batch-set-style through normalizePointRange before sending.
Multi-cell spans pass through unchanged.
2026-04-18 23:29:14 +08:00
caojie0621
abb02cd46c feat(sheets): add float image shortcuts (#494)
Implement +create-float-image, +update-float-image, +get-float-image,
+list-float-images, and +delete-float-image shortcuts wrapping the v3
spreadsheet float_image API. The create reference doc includes the
prerequisite media upload step with the correct parent_type
(sheet_image) to avoid common token mismatch errors.
2026-04-18 23:27:11 +08:00
haozhenghua-code
db7d3cb64d fix(im): cap basic_batch user_ids at 10 per API limit (#551)
The POST /contact/v3/users/basic_batch endpoint caps user_ids at 1~10
per request, but batchResolveByBasicContact was chunking by 50. When
user identity needed to resolve >10 unresolved sender names, the
single oversized request was rejected, causing the batch resolver to
bail out and leave sender names empty for the rest.

Lower batchSize to 10 and add a unit test that exercises 25 missing
IDs and asserts they are sent as 10 / 10 / 5.
2026-04-18 18:41:30 +08:00
Paulazaaza-dev
5134719da9 feat: add remind/initiated method (#554)
Change-Id: I27c00d96a9478efbf39fbc1118bb6bcb75fe6b14
2026-04-18 17:46:23 +08:00
zkh-bytedance
5a0e1d3dd9 fix(whiteboard): Deprecate old lark-whiteboard-cli skill (#547) 2026-04-18 00:36:56 +08:00
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
194 changed files with 14018 additions and 1316 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

2
.gitignore vendored
View File

@@ -36,3 +36,5 @@ tests/mail/reports/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo

View File

@@ -2,6 +2,44 @@
All notable changes to this project will be documented in this file.
## [v1.0.15] - 2026-04-20
### Features
- **sheets**: Add float image shortcuts (#494)
- **approval**: Document `remind` and `initiated` methods in skill (#554)
### Bug Fixes
- **base**: Preserve attachment metadata on base uploads (#563)
- **base**: Fix role view and record default permission on edit (#530)
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
- **install**: Refine install wizard messages (#529)
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
## [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 +420,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
[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

@@ -3,7 +3,10 @@
package credential
import "sync"
import (
"sort"
"sync"
)
var (
mu sync.Mutex
@@ -11,12 +14,28 @@ var (
)
// Register registers a credential Provider.
// Providers are consulted in registration order.
// Providers are consulted in priority order (lowest value first).
// Providers that implement Priority() int are sorted accordingly;
// those that do not default to priority 10.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
sort.SliceStable(providers, func(i, j int) bool {
return providerPriority(providers[i]) < providerPriority(providers[j])
})
}
// providerPriority returns the priority of a provider.
// If the provider implements interface{ Priority() int }, that value is used;
// otherwise 10 is returned as the default priority.
// Lower values are consulted first.
func providerPriority(p Provider) int {
if pp, ok := p.(interface{ Priority() int }); ok {
return pp.Priority()
}
return 10
}
// Providers returns all registered providers (snapshot).

View File

@@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) {
}
}
type priorityProvider struct {
stubProvider
priority int
}
func (p *priorityProvider) Priority() int { return p.priority }
func TestRegister_PriorityOrder(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "env"}) // priority 10 (default)
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
got := Providers()
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
}
}
func TestProviders_ReturnsSnapshot(t *testing.T) {
mu.Lock()
old := providers

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a noop credential provider for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
// placeholder credentials so the CLI's auth pipeline can proceed normally.
// Real tokens are never present in the sandbox; the sidecar transport
// interceptor routes requests to the trusted sidecar process instead.
package sidecar
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider is the noop credential provider for sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
func (p *Provider) Priority() int { return 0 }
// ResolveAccount returns a minimal Account when sidecar mode is active.
// The account contains AppID and Brand from environment variables, a
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil, nil // not in sidecar mode, skip
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
}
}
appID := os.Getenv(envvars.CliAppID)
if appID == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
}
}
if os.Getenv(envvars.CliProxyKey) == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
}
// Parse DefaultAs
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
case "off", "":
acct.SupportedIdentities = credential.SupportsAll
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return acct, nil
}
// ResolveToken returns a sentinel token whose value encodes the token type.
// The transport interceptor reads this sentinel to determine the identity
// (user vs bot), strips it, and the sidecar injects the real token.
// Returns nil, nil when sidecar mode is not active.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
if os.Getenv(envvars.CliAuthProxy) == "" {
return nil, nil
}
var sentinel string
switch req.Type {
case credential.TokenTypeUAT:
sentinel = sidecar.SentinelUAT
case credential.TokenTypeTAT:
sentinel = sidecar.SentinelTAT
default:
return nil, nil
}
return &credential.Token{
Value: sentinel,
Scopes: "", // empty → scope pre-check is skipped
Source: "sidecar",
}, nil
}
func init() {
credential.Register(&Provider{})
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
func setEnv(t *testing.T, key, value string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Setenv(key, value)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
} else {
os.Unsetenv(key)
}
})
}
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
}
})
}
func TestResolveAccount_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct != nil {
t.Fatal("expected nil account when AUTH_PROXY not set")
}
}
func TestResolveAccount_Active(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test123")
setEnv(t, envvars.CliBrand, "lark")
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct == nil {
t.Fatal("expected non-nil account")
}
if acct.AppID != "cli_test123" {
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
}
if acct.Brand != credential.BrandLark {
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
}
if acct.AppSecret != credential.NoAppSecret {
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
}
}
func TestResolveAccount_MissingProxyKey(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
unsetEnv(t, envvars.CliProxyKey)
setEnv(t, envvars.CliAppID, "cli_test")
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when PROXY_KEY is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_MissingAppID(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
unsetEnv(t, envvars.CliAppID)
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when APP_ID is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_StrictMode(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
mode string
want credential.IdentitySupport
}{
{"bot", credential.SupportsBot},
{"user", credential.SupportsUser},
{"off", credential.SupportsAll},
{"", credential.SupportsAll},
}
p := &Provider{}
for _, tt := range tests {
t.Run("strict_"+tt.mode, func(t *testing.T) {
if tt.mode == "" {
unsetEnv(t, envvars.CliStrictMode)
} else {
setEnv(t, envvars.CliStrictMode, tt.mode)
}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct.SupportedIdentities != tt.want {
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != nil {
t.Fatal("expected nil token when AUTH_PROXY not set")
}
}
func TestResolveToken_Sentinels(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
p := &Provider{}
// UAT
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("UAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelUAT {
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
}
if tok.Scopes != "" {
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
}
// TAT
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("TAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelTAT {
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
// outgoing requests are rewritten to the sidecar address. The interceptor
// strips placeholder credentials, injects proxy headers, and signs each
// request with HMAC-SHA256. No custom DialContext is needed — Go's
// standard http.Transport connects to the sidecar via plain HTTP.
package sidecar
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider implements transport.Provider for the sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
// in the latter case a warning is emitted to stderr and requests fall back to
// the non-sidecar transport path (where the credential layer will typically
// block them for lack of a valid account).
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
return nil
}
key := os.Getenv(envvars.CliProxyKey)
return &Interceptor{
key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr),
}
}
// Interceptor rewrites requests for the sidecar proxy.
type Interceptor struct {
key []byte // HMAC signing key
sidecarHost string // sidecar host:port for URL rewriting
}
// PreRoundTrip rewrites the request for sidecar routing when it carries a
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
// URLs) are passed through unmodified.
//
// Supports two auth patterns:
// - Standard OpenAPI: Authorization: Bearer <sentinel>
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
identity, authHeader := detectSentinel(req)
if identity == "" {
return nil // not a sidecar-managed request, pass through
}
// 1. Buffer the body first, before mutating any request state. A partial
// read would sign a truncated body and cause a misleading HMAC mismatch
// on the sidecar side; bail out early and let the request fall through
// unmodified so the credential layer can surface an actionable error.
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
return nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if req.GetBody != nil {
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
}
}
// 2. Save original target (scheme://host)
originalScheme := "https"
if req.URL.Scheme != "" {
originalScheme = req.URL.Scheme
}
originalHost := req.URL.Host
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
// 3. Set identity and tell sidecar which header to inject real token into
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
// 4. Strip placeholder auth header(s)
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
bodySHA := sidecar.BodySHA256(bodyBytes)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
pathAndQuery := req.URL.RequestURI()
ts := sidecar.Timestamp()
// Cover identity and authHeader in the signature so an on-path attacker
// within the replay window cannot flip the injected token's identity or
// redirect the token into a different header.
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: req.Method,
Host: originalHost,
PathAndQuery: pathAndQuery,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
// 5. Rewrite URL to route through sidecar
req.URL.Scheme = "http"
req.URL.Host = i.sidecarHost
return nil // no post-hook needed
}
// detectSentinel checks both standard Authorization and MCP auth headers for
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
// that carried the sentinel.
//
// Returns ("", "") when the request carries no sentinel token — typically
// requests that require no auth (e.g. pre-signed download URLs where the
// token is embedded in the URL query parameters).
func detectSentinel(req *http.Request) (identity, authHeader string) {
// Check standard Authorization: Bearer <sentinel>
if auth := req.Header.Get("Authorization"); auth != "" {
token := strings.TrimPrefix(auth, "Bearer ")
switch token {
case sidecar.SentinelUAT:
return sidecar.IdentityUser, "Authorization"
case sidecar.SentinelTAT:
return sidecar.IdentityBot, "Authorization"
}
}
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
}
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
}
return "", ""
}
func init() {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
return
}
transport.Register(&Provider{})
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"bytes"
"errors"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/sidecar"
)
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
closed bool
readCall bool
}
func (b *failingBody) Read(p []byte) (int, error) {
b.readCall = true
return 0, b.err
}
func (b *failingBody) Close() error {
b.closed = true
return nil
}
func TestInterceptor_PreRoundTrip(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
body := []byte(`{"msg":"hello"}`)
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
req.Header.Set("X-Cli-Source", "lark-cli")
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook")
}
// URL should be rewritten to sidecar
if req.URL.Scheme != "http" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
}
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
}
// Original target should be preserved
target := req.Header.Get(sidecar.HeaderProxyTarget)
if target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Identity should be user (from SentinelUAT)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
// Authorization should be stripped
if auth := req.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization header should be stripped, got %q", auth)
}
// HMAC headers should be set
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
t.Error("timestamp header should be set")
}
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
t.Error("body SHA256 header should be set")
}
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
}
// Non-proxy headers should be preserved
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
t.Errorf("X-Cli-Source should be preserved, got %q", src)
}
// Body should still be readable
readBody, _ := io.ReadAll(req.Body)
if !bytes.Equal(readBody, body) {
t.Errorf("body should be preserved after PreRoundTrip")
}
}
func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
}
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
req, _ := http.NewRequest("GET", origURL, nil)
req.Header.Set("Authorization", "Bearer some-real-token")
post := interceptor.PreRoundTrip(req)
// Should NOT be rewritten — no sentinel token
if post != nil {
t.Error("expected nil post hook for pass-through")
}
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged, got %q", req.URL.String())
}
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
t.Error("proxy target header should not be set for pass-through")
}
if req.Header.Get("Authorization") != "Bearer some-real-token" {
t.Error("Authorization should be preserved for pass-through")
}
}
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://cdn.feishu.cn/download/file"
req, _ := http.NewRequest("GET", origURL, nil)
interceptor.PreRoundTrip(req)
// No Authorization header at all — should pass through
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
}
}
func TestInterceptor_MCP_UAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
// Should be intercepted and rewritten
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want sidecar host", req.URL.Host)
}
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
}
// MCP sentinel should be stripped
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("MCP-UAT should be stripped, got %q", v)
}
}
func TestInterceptor_MCP_TAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
}
}
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
t.Errorf("auth header = %q, want %q", ah, "Authorization")
}
}
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
// signing a truncated body (which would produce a misleading HMAC mismatch on
// the sidecar side) and releases the original body.
func TestInterceptor_BodyReadError(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
body := &failingBody{err: errors.New("disk gremlin")}
req, _ := http.NewRequest("POST", origURL, body)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook on body read failure")
}
// Original body must be closed to avoid leaking fd/pipe-like resources.
if !body.readCall {
t.Error("expected ReadAll to have attempted reading from the body")
}
if !body.closed {
t.Error("expected original body to be Close()'d after read failure")
}
// URL must NOT be rewritten — request should fall through to the next
// layer (credential) which can surface a meaningful error.
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
}
// No proxy/HMAC headers should leak onto the request.
for _, h := range []string{
sidecar.HeaderProxyVersion,
sidecar.HeaderProxyTarget,
sidecar.HeaderProxySignature,
sidecar.HeaderProxyTimestamp,
sidecar.HeaderBodySHA256,
sidecar.HeaderProxyIdentity,
sidecar.HeaderProxyAuthHeader,
} {
if v := req.Header.Get(h); v != "" {
t.Errorf("%s should not be set on read failure, got %q", h, v)
}
}
}
func TestInterceptor_EmptyBody(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
sha := req.Header.Get(sidecar.HeaderBodySHA256)
expectedEmpty := sidecar.BodySHA256(nil)
if sha != expectedEmpty {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
// Add top-level JSON keys as text form fields.
if m, ok := dataJSON.(map[string]any); ok {
for k, v := range m {
fd.AddField(k, fmt.Sprintf("%v", v))
fd.AddField(k, formatFormFieldValue(v))
}
}
return fd, nil
}
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
// field string. float64 is handled specially: fmt's default %v/%g switches to
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
// backends reject when parsing the field as an integer. Use decimal notation
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
// All other types fall through to %v.
func formatFormFieldValue(v any) string {
if n, ok := v.(float64); ok {
return strconv.FormatFloat(n, 'f', -1, 64)
}
return fmt.Sprintf("%v", v)
}

View File

@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
}
})
}
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
// float64 delegates to %g which switches to scientific notation at ~1e6
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
// integer reject that, surfacing as a generic "params error".
func TestFormatFormFieldValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in any
want string
}{
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
{"float64 below scientific threshold", float64(358934), "358934"},
{"float64 zero", float64(0), "0"},
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
{"float64 negative", float64(-42), "-42"},
{"float64 fractional preserved", float64(3.14), "3.14"},
{"string pass-through", "hello", "hello"},
{"bool true", true, "true"},
{"int via %v", 42, "42"},
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatFormFieldValue(tt.in)
if got != tt.want {
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -11,4 +11,8 @@ const (
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
)

View File

@@ -38,6 +38,9 @@ const (
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -73,6 +76,12 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
// sheets-specific constraints that benefit from actionable hints
case LarkErrSheetsFloatImageInvalidDims:
return ExitAPI, "invalid_params",
"check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
}
return ExitAPI, "api_error", ""

View File

@@ -40,6 +40,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
wantType: "cross_brand",
wantHint: "same brand environment",
},
{
name: "sheets float image invalid dims",
code: LarkErrSheetsFloatImageInvalidDims,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantHint: "--width / --height / --offset-x / --offset-y",
},
}
for _, tt := range tests {

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

11
main_authsidecar.go Normal file
View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package main
import (
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
)

54
main_noauthsidecar.go Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
// This file is the fail-closed guard for builds that do NOT include the
// `authsidecar` tag. The sidecar credential-isolation feature is only
// compiled in under that tag; deploying the plain build into an environment
// that expects sidecar isolation would silently fall back to direct env
// credential use — exactly the failure mode the feature is meant to prevent.
//
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
// the variable. The operator either rebuilt without realizing (wrong
// artifact) or the sandbox inherited the var by accident; both cases want
// a loud startup error, not a mysterious token leak on the first API call.
package main
import (
"fmt"
"io"
"os"
"github.com/larksuite/cli/internal/envvars"
)
func init() {
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
os.Exit(code)
}
}
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
// human-readable reason to stderr) when the environment asks for sidecar
// isolation that this binary cannot provide. Factored out from init() so
// tests can exercise the decision without actually calling os.Exit.
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
v := getenv(envvars.CliAuthProxy)
if v == "" {
return 0
}
fmt.Fprintf(stderr,
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
"'authsidecar' build tag.\n"+
"The sidecar credential-isolation feature is compiled out — "+
"running would bypass isolation and\n"+
"send any real credentials present in the environment directly "+
"to the Lark API.\n\n"+
"To fix, either:\n"+
" - rebuild the CLI with: go build -tags authsidecar\n"+
" - or unset %s if sidecar isolation is not required\n",
envvars.CliAuthProxy, envvars.CliAuthProxy)
return 2
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
package main
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
var stderr bytes.Buffer
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
if code != 0 {
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
}
if stderr.Len() != 0 {
t.Errorf("stderr should be empty, got %q", stderr.String())
}
}
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
// a sandbox that expects sidecar isolation fails loudly at startup instead
// of silently leaking credentials through the env provider path.
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
var stderr bytes.Buffer
env := func(k string) string {
if k == envvars.CliAuthProxy {
return "http://127.0.0.1:16384"
}
return ""
}
code := checkNoAuthsidecarBuild(env, &stderr)
if code == 0 {
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
}
msg := stderr.String()
for _, want := range []string{
envvars.CliAuthProxy,
"authsidecar", // build-tag name must appear so operators can act on it
"rebuild",
} {
if !strings.Contains(msg, want) {
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.13",
"version": "1.0.15",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -38,11 +38,11 @@ const messages = {
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等",
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n现在可以你的 AI 工具Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
done: "安装完成!\n可以你的 AI 工具(Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
},
en: {
@@ -66,7 +66,7 @@ const messages = {
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
step4Done: "Authorization complete",
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",

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

@@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) {
"bitable_file",
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
"report-final.pdf",
`"mime_type":"\u003cdetected_mime_type\u003e"`,
`"size":"\u003cfile_size\u003e"`,
"deprecated_set_attachment",
)
}

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) {
@@ -941,7 +1219,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
!strings.Contains(updateBody, `"image_height":480`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
!strings.Contains(updateBody, `"name":"report.txt"`) {
!strings.Contains(updateBody, `"name":"report.txt"`) ||
!strings.Contains(updateBody, `"size":16`) ||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
t.Fatalf("update body=%s", updateBody)
}
})
@@ -1092,6 +1372,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(updateBody, `"附件"`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
!strings.Contains(updateBody, `"size":20971521`) ||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
t.Fatalf("update body=%s", updateBody)
}

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

@@ -4,11 +4,15 @@
package base
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
@@ -105,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
map[string]interface{}{
"file_token": "<uploaded_file_token>",
"name": fileName,
"mime_type": "<detected_mime_type>",
"size": "<file_size>",
"deprecated_set_attachment": true,
},
},
@@ -243,10 +249,14 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
if err != nil {
return nil, err
}
parentNode := baseToken
var (
fileToken string
err error
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
@@ -272,7 +282,78 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
attachment := map[string]interface{}{
"file_token": fileToken,
"name": fileName,
"mime_type": mimeType,
"size": fileSize,
"deprecated_set_attachment": true,
}
return attachment, nil
}
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
return stripMIMEParams(byExt), nil
}
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
return stripMIMEParams(byExt), nil
}
f, err := fio.Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
buf := make([]byte, 512)
n, readErr := f.Read(buf)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return "", output.ErrValidation("cannot read file: %s", readErr)
}
return detectAttachmentMIMEFromContent(buf[:n]), nil
}
func stripMIMEParams(value string) string {
if i := strings.IndexByte(value, ';'); i != -1 {
value = value[:i]
}
return strings.TrimSpace(value)
}
func detectAttachmentMIMEFromContent(content []byte) string {
if len(content) == 0 {
return "application/octet-stream"
}
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
return "image/png"
}
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
return "image/jpeg"
}
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
return "image/gif"
}
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
return "image/webp"
}
if bytes.HasPrefix(content, []byte("%PDF-")) {
return "application/pdf"
}
if looksLikeText(content) {
return "text/plain"
}
return "application/octet-stream"
}
func looksLikeText(content []byte) bool {
if !utf8.Valid(content) {
return false
}
for _, r := range string(content) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"io"
"io/fs"
"os"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
type attachmentTestFileIO struct {
openFile fileio.File
openErr error
}
func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr }
func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) {
return attachmentTestFileInfo{}, nil
}
func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type attachmentTestFileInfo struct{}
func (attachmentTestFileInfo) Size() int64 { return 0 }
func (attachmentTestFileInfo) IsDir() bool { return false }
func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 }
type attachmentTestFile struct {
*bytes.Reader
}
func newAttachmentTestFile(content []byte) attachmentTestFile {
return attachmentTestFile{Reader: bytes.NewReader(content)}
}
func (attachmentTestFile) Close() error { return nil }
type attachmentReadErrorFile struct{}
func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission }
func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF }
func (attachmentReadErrorFile) Close() error { return nil }
func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) {
got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "text/plain" {
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
}
}
func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) {
got, err := detectAttachmentMIMEType(nil, "report.docx", "report")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got)
}
}
func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))}
got, err := detectAttachmentMIMEType(fio, "note", "note")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "text/plain" {
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
}
}
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
_, err := detectAttachmentMIMEType(fio, "missing", "missing")
if err == nil {
t.Fatal("expected error for open failure")
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("error = %v, want wrapped read failure", err)
}
}
func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) {
fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}}
_, err := detectAttachmentMIMEType(fio, "broken", "broken")
if err == nil {
t.Fatal("expected error for read failure")
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("error = %v, want read failure", err)
}
}
func TestDetectAttachmentMIMEFromContent(t *testing.T) {
tests := []struct {
name string
content []byte
want string
}{
{name: "empty", content: nil, want: "application/octet-stream"},
{name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"},
{name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"},
{name: "gif87a", content: []byte("GIF87a"), want: "image/gif"},
{name: "gif89a", content: []byte("GIF89a"), want: "image/gif"},
{name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"},
{name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"},
{name: "text", content: []byte("hello from base attachment"), want: "text/plain"},
{name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"},
{name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"},
{name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectAttachmentMIMEFromContent(tt.content)
if got != tt.want {
t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want)
}
})
}
}

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

@@ -152,8 +152,9 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
// This API has lighter permission requirements and works with user identity
// even when the target user is not in the app's visible range.
// Response uses "users" (not "items") and "user_id" (not "open_id").
// The basic_batch endpoint caps user_ids at 10 per request.
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
const batchSize = 50
const batchSize = 10
for i := 0; i < len(missingIDs); i += batchSize {
end := i + batchSize
if end > len(missingIDs) {

View File

@@ -4,7 +4,9 @@
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
@@ -170,6 +172,57 @@ func TestResolveSenderNames(t *testing.T) {
}
}
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
// expect three requests with sizes 10 / 10 / 5.
var batchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
userIDs, _ := payload["user_ids"].([]interface{})
if len(userIDs) > 10 {
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
}
batchSizes = append(batchSizes, len(userIDs))
users := make([]interface{}, 0, len(userIDs))
for _, raw := range userIDs {
id, _ := raw.(string)
users = append(users, map[string]interface{}{
"user_id": id,
"name": "name-" + id,
})
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"users": users},
}), nil
}))
missingIDs := make([]string, 25)
for i := range missingIDs {
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
}
nameMap := map[string]string{}
batchResolveByBasicContact(runtime, missingIDs, nameMap)
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
}
if len(nameMap) != 25 {
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
}
}
func TestResolveSenderNamesAPIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {

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

@@ -22,7 +22,7 @@ var SheetBatchSetStyle = common.Shortcut{
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
@@ -49,6 +49,7 @@ var SheetBatchSetStyle = common.Shortcut{
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
normalizeBatchStyleRanges(data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
Body(map[string]interface{}{
@@ -66,6 +67,7 @@ var SheetBatchSetStyle = common.Shortcut{
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
normalizeBatchStyleRanges(data)
result, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
@@ -81,3 +83,34 @@ var SheetBatchSetStyle = common.Shortcut{
return nil
},
}
// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place
// so the /styles_batch_update endpoint accepts single-cell shorthand.
// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to
// "sheet1!A1:A1"; multi-cell spans pass through unchanged.
// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be
// expanded because the helper has no sheet-id context (the shortcut exposes
// no --sheet-id flag), and the backend would reject the payload anyway —
// such entries pass through unchanged. Non-string entries, missing
// ranges keys, and non-array top-level inputs are ignored silently.
func normalizeBatchStyleRanges(data interface{}) {
items, ok := data.([]interface{})
if !ok {
return
}
for _, item := range items {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
ranges, ok := entry["ranges"].([]interface{})
if !ok {
continue
}
for i, r := range ranges {
if s, ok := r.(string); ok {
ranges[i] = normalizePointRange("", s)
}
}
}
}

View File

@@ -414,6 +414,46 @@ func TestSheetSetStyleExecuteSuccess(t *testing.T) {
}
}
func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1",
"style": `{"font":{"bold":true}}`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"range":"sheet1!A1:A1"`) {
t.Fatalf("DryRun should expand single cell to A1:A1: %s", got)
}
}
func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "A1",
"--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
appendStyle, _ := body["appendStyle"].(map[string]interface{})
if appendStyle["range"] != "sheet1!A1:A1" {
t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"])
}
}
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
@@ -523,6 +563,51 @@ func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
}
}
func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) {
t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got)
}
}
func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"totalUpdatedCells": float64(5),
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
data, _ := body["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 data entry, got %d", len(data))
}
entry, _ := data[0].(map[string]interface{})
ranges, _ := entry["ranges"].([]interface{})
if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" {
t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges)
}
}
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
@@ -537,3 +622,101 @@ func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
t.Fatal("expected error")
}
}
func TestNormalizeBatchStyleRanges(t *testing.T) {
t.Parallel()
t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1", "sheet1!B2"},
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" {
t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got)
}
})
t.Run("multi-cell span passes through unchanged", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1:B2"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:B2" {
t.Fatalf("multi-cell span should be unchanged, got %v", got[0])
}
})
t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) {
t.Parallel()
// Without a sheetId! prefix there's no sheet context; entry is left
// alone and the backend will reject it. Documented in the helper.
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"A1"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "A1" {
t.Fatalf("bare single cell should pass through, got %v", got[0])
}
})
t.Run("non-string entries are preserved", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"},
},
}
normalizeBatchStyleRanges(data)
got := data[0].(map[string]interface{})["ranges"].([]interface{})
if got[0] != "sheet1!A1:A1" {
t.Fatalf("first entry should be expanded, got %v", got[0])
}
if got[1] != 42 {
t.Fatalf("int entry should be preserved, got %v", got[1])
}
if got[2] != nil {
t.Fatalf("nil entry should be preserved, got %v", got[2])
}
if got[3] != "sheet1!B2:B2" {
t.Fatalf("last entry should be expanded, got %v", got[3])
}
})
t.Run("missing or non-array ranges key is skipped", func(t *testing.T) {
t.Parallel()
data := []interface{}{
map[string]interface{}{
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
},
map[string]interface{}{
"ranges": "not-an-array",
},
"not-a-map",
}
normalizeBatchStyleRanges(data)
if data[1].(map[string]interface{})["ranges"] != "not-an-array" {
t.Fatal("non-array ranges should be left alone")
}
})
t.Run("top-level non-array inputs do not panic", func(t *testing.T) {
t.Parallel()
// Any of these would panic if the helper didn't guard its type assertions.
normalizeBatchStyleRanges(nil)
normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"})
normalizeBatchStyleRanges("string")
normalizeBatchStyleRanges(42)
})
}

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func floatImageBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func floatImageItemPath(token, sheetID, floatImageID string) string {
return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID))
}
func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != u {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func validateFloatImageRange(sheetID, rangeVal string) error {
if rangeVal == "" {
return nil
}
if err := validateSingleCellRange(rangeVal); err != nil {
return err
}
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
}
return nil
}
// validateFloatImageUpdatePayload rejects an update request that carries no
// mutable field. Without this, PATCH {} reaches the server as a confusing
// no-op or opaque error.
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
hasField := runtime.Str("range") != "" ||
runtime.Cmd.Flags().Changed("width") ||
runtime.Cmd.Flags().Changed("height") ||
runtime.Cmd.Flags().Changed("offset-x") ||
runtime.Cmd.Flags().Changed("offset-y")
if !hasField {
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
}
return nil
}
// validateFloatImageDims checks the numeric bounds we can verify without
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
// The upper bounds (offset < anchor cell's width/height) are validated by
// the server and surfaced through the 1310246 error hint.
// Only flags explicitly supplied by the user are checked, so omitted flags
// (which fall back to server defaults) pass through unchanged.
func validateFloatImageDims(runtime *common.RuntimeContext) error {
if runtime.Cmd.Flags().Changed("width") {
if v := runtime.Int("width"); v < 20 {
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("height") {
if v := runtime.Int("height"); v < 20 {
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-x") {
if v := runtime.Int("offset-x"); v < 0 {
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-y") {
if v := runtime.Int("offset-y"); v < 0 {
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
}
}
return nil
}
func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} {
body := map[string]interface{}{}
if includeToken {
if s := runtime.Str("float-image-token"); s != "" {
body["float_image_token"] = s
}
}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if runtime.Cmd.Flags().Changed("width") {
body["width"] = runtime.Int("width")
}
if runtime.Cmd.Flags().Changed("height") {
body["height"] = runtime.Int("height")
}
if runtime.Cmd.Flags().Changed("offset-x") {
body["offset_x"] = runtime.Int("offset-x")
}
if runtime.Cmd.Flags().Changed("offset-y") {
body["offset_y"] = runtime.Int("offset-y")
}
return body
}
// SheetCreateFloatImage creates a float image on a sheet.
var SheetCreateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+create-float-image",
Description: "Create a floating image on a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-token", Desc: "image file token (from upload API)", Required: true},
{Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true},
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
{Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFloatImageToken(runtime); err != nil {
return err
}
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return validateFloatImageDims(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, true)
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, true)
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateFloatImage updates a float image's properties.
var SheetUpdateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+update-float-image",
Description: "Update a floating image",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
{Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"},
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFloatImageToken(runtime); err != nil {
return err
}
if err := validateFloatImageUpdatePayload(runtime); err != nil {
return err
}
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return validateFloatImageDims(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetFloatImage retrieves a single float image.
var SheetGetFloatImage = common.Shortcut{
Service: "sheets",
Command: "+get-float-image",
Description: "Get a floating image by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetListFloatImages queries all float images in a sheet.
var SheetListFloatImages = common.Shortcut{
Service: "sheets",
Command: "+list-float-images",
Description: "List all floating images in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteFloatImage deletes a float image.
var SheetDeleteFloatImage = common.Shortcut{
Service: "sheets",
Command: "+delete-float-image",
Description: "Delete a floating image",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "float-image-id", Desc: "float image ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFloatImageToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFloatImageToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,524 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── CreateFloatImage ────────────────────────────────────────────────────────
func TestCreateFloatImageValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFloatImageValidateSuccess(t *testing.T) {
t.Parallel()
// Pixel flags are int-typed by the shortcut; leave them unset (empty
// intFlags map) so Cmd.Flags().Changed(...) returns false and
// validateFloatImageDims doesn't try to read non-existent ints.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
}, nil, nil)
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:B2",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "single cell") {
t.Fatalf("expected single-cell error, got: %v", err)
}
}
func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-token": "boxToken", "range": "other!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
}, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
}
}
func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
t.Parallel()
tests := []struct {
name string
intFlags map[string]int
wantSubst string
}{
{"width below 20", map[string]int{"width": 5}, "--width must be >= 20"},
{"height below 20", map[string]int{"height": 10}, "--height must be >= 20"},
{"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"},
{"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"},
}
baseStr := map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
err := SheetCreateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) {
t.Parallel()
// Boundary values exactly at the lower bound should pass.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
},
map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil)
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("boundary values should pass, got: %v", err)
}
}
func TestCreateFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "",
},
map[string]int{"width": 200, "height": 150}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `float_images`) {
t.Fatalf("DryRun URL missing float_images: %s", got)
}
if !strings.Contains(got, `"float_image_token":"boxToken"`) {
t.Fatalf("DryRun missing float_image_token: %s", got)
}
if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) {
t.Fatalf("DryRun should emit numeric width/height, got: %s", got)
}
}
func TestCreateFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{
"float_image_id": "fi12345678", "float_image_token": "boxToken",
"range": "sheet1!A1:A1", "width": 200, "height": 150,
},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--width", "200", "--height", "150",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "float_image_id") {
t.Fatalf("stdout missing float_image_id: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["float_image_token"] != "boxToken" {
t.Fatalf("unexpected float_image_token: %v", body["float_image_token"])
}
if w, ok := body["width"].(float64); !ok || w != 200 {
t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"])
}
if h, ok := body["height"].(float64); !ok || h != 150 {
t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"])
}
}
func TestCreateFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi12345678"},
}},
})
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFloatImageExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
"--range", "sheet1!A1:A1", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UpdateFloatImage ────────────────────────────────────────────────────────
func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) {
t.Parallel()
// Only IDs set, no mutable field: PATCH would be an empty {} body.
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
}, nil, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") {
t.Fatalf("expected empty-payload error, got: %v", err)
}
}
func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) {
t.Parallel()
// Any single mutable field should satisfy the payload check.
tests := []struct {
name string
strFlags map[string]string
intFlags map[string]int
}{
{
name: "range only",
strFlags: map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "sheet1!B2:B2",
},
},
{
name: "offset-x only (zero value)",
strFlags: map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
},
intFlags: map[string]int{"offset-x": 0},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "other!A1:A1",
"width": "", "height": "", "offset-x": "", "offset-y": "",
}, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
}
}
func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
t.Parallel()
tests := []struct {
name string
intFlags map[string]int
wantSubst string
}{
{"width below 20", map[string]int{"width": 19}, "--width must be >= 20"},
{"height below 20", map[string]int{"height": 0}, "--height must be >= 20"},
{"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"},
{"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"},
}
baseStr := map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
"float-image-id": "fi123", "range": "",
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestUpdateFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"float-image-id": "fi12345678", "range": "sheet1!B2:B2",
},
map[string]int{"width": 300, "offset-y": 10}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PATCH"`) {
t.Fatalf("DryRun should use PATCH: %s", got)
}
if !strings.Contains(got, `fi12345678`) {
t.Fatalf("DryRun missing float_image_id: %s", got)
}
if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) {
t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got)
}
}
func TestUpdateFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300},
}},
})
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
"+update-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123",
"--width", "300", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
"+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123",
"--range", "sheet1!C3:C3", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetFloatImage ───────────────────────────────────────────────────────────
func TestGetFloatImageValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1",
}, nil)
err := SheetGetFloatImage.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `fi123`) {
t.Fatalf("DryRun missing float_image_id: %s", got)
}
}
func TestGetFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{
"float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100,
},
}},
})
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
"+get-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fi123") {
t.Fatalf("stdout missing fi123: %s", stdout.String())
}
}
func TestGetFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"float_image": map[string]interface{}{"float_image_id": "fi123"},
}},
})
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
"+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── ListFloatImages ─────────────────────────────────────────────────────────
func TestListFloatImagesDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt))
if !strings.Contains(got, `float_images/query`) {
t.Fatalf("DryRun URL missing query: %s", got)
}
}
func TestListFloatImagesExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"float_image_id": "fi1"},
map[string]interface{}{"float_image_id": "fi2"},
},
}},
})
err := mountAndRunSheets(t, SheetListFloatImages, []string{
"+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fi1") {
t.Fatalf("stdout missing fi1: %s", stdout.String())
}
}
func TestListFloatImagesWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFloatImages, []string{
"+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFloatImage ────────────────────────────────────────────────────────
func TestDeleteFloatImageDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFloatImageExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
"+delete-float-image", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFloatImageWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
"+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// sheetImageParentType is the parent_type accepted by the drive media upload
// endpoint for media that will be anchored via +create-float-image.
const sheetImageParentType = "sheet_image"
// SheetMediaUpload uploads a local image to the drive media endpoint against
// a spreadsheet and returns the file_token. The token is usable as the
// --float-image-token argument to +create-float-image.
//
// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are
// streamed via upload_prepare / upload_part / upload_finish. This matches the
// pattern used by docs +media-upload and drive +import.
var SheetMediaUpload = common.Shortcut{
Service: "sheets",
Command: "+media-upload",
Description: "Upload a local image for use as a floating image and return the file_token",
Risk: "write",
Scopes: []string{"docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
filePath := runtime.Str("file")
fileName := filepath.Base(filePath)
dry := common.NewDryRunAPI()
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
dry.Desc("chunked media upload (files > 20MB)").
POST("/open-apis/drive/v1/medias/upload_prepare").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry.Set("spreadsheet_token", parentNode)
}
return dry.Desc("multipart/form-data upload").
POST("/open-apis/drive/v1/medias/upload_all").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
"file": "@" + filePath,
}).
Set("spreadsheet_token", parentNode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return err
}
filePath := runtime.Str("file")
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": stat.Size(),
"spreadsheet_token": parentNode,
}, nil)
return nil
},
}
// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node,
// accepting either --url or --spreadsheet-token.
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != "" {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
// uploadSheetMediaFile routes to the single-part or multipart upload path based
// on file size. Always uses parent_type=sheet_image so the returned token can
// be consumed by +create-float-image.
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
pn := parentNode
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: &pn,
})
}
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: parentNode,
})
}
// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses
// local stat as a best-effort planning hint. Execute re-validates before
// choosing the actual upload path.
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
info, err := fio.Stat(filePath)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload", "--file", "img.png", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out)
}
if strings.Contains(out, "upload_prepare") {
t.Fatalf("dry-run should not use multipart for small file, got: %s", out)
}
}
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "shtFromURL") {
t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String())
}
}
func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
// Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk.
largeFile, err := os.Create("big.png")
if err != nil {
t.Fatal(err)
}
if err := largeFile.Truncate(20*1024*1024 + 1); err != nil {
t.Fatal(err)
}
_ = largeFile.Close()
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err = mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "big.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v1/medias/upload_prepare",
"/open-apis/drive/v1/medias/upload_part",
"/open-apis/drive/v1/medias/upload_finish",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run should include %q for large file, got: %s", want, out)
}
}
if strings.Contains(out, "upload_all") {
t.Fatalf("dry-run should not use upload_all for large file, got: %s", out)
}
}
func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "boxTOK123"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "img.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("parse output: %v", err)
}
data, _ := envelope["data"].(map[string]interface{})
if data["file_token"] != "boxTOK123" {
t.Fatalf("file_token = %v, want boxTOK123", data["file_token"])
}
if data["spreadsheet_token"] != "shtSTUB" {
t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"])
}
body := decodeSheetsMultipartBody(t, stub)
if got := body.Fields["parent_type"]; got != sheetImageParentType {
t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType)
}
if got := body.Fields["parent_node"]; got != "shtSTUB" {
t.Fatalf("parent_node = %q, want shtSTUB", got)
}
if got := body.Fields["file_name"]; got != "img.png" {
t.Fatalf("file_name = %q, want img.png", got)
}
if got := body.Fields["size"]; got != "9" {
t.Fatalf("size = %q, want 9 (len of png-bytes)", got)
}
}
func TestSheetMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSheetsTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "shtSTUB",
"--file", "missing.png",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
t.Fatalf("err = %v, want file-not-found error", err)
}
}
// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with
// t.Parallel — chdir is process-wide.
func withSheetsTestWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
}
type capturedSheetsMultipart struct {
Fields map[string]string
Files map[string][]byte
}
func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
for {
part, err := reader.NextPart()
if err != nil {
break
}
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(part)
if part.FileName() != "" {
body.Files[part.FormName()] = buf.Bytes()
continue
}
body.Fields[part.FormName()] = buf.String()
}
return body
}

View File

@@ -51,7 +51,7 @@ var SheetSetStyle = common.Shortcut{
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
return common.NewDryRunAPI().
@@ -70,7 +70,7 @@ var SheetSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)

View File

@@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut {
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
SheetMediaUpload,
SheetCreateFloatImage,
SheetUpdateFloatImage,
SheetGetFloatImage,
SheetListFloatImages,
SheetDeleteFloatImage,
}
}

88
sidecar/hmac.go Normal file
View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sidecar
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// BodySHA256 returns the hex-encoded SHA-256 digest of body.
// An empty or nil body produces the SHA-256 of the empty string.
func BodySHA256(body []byte) string {
h := sha256.Sum256(body)
return hex.EncodeToString(h[:])
}
// CanonicalRequest is the set of fields covered by the HMAC signature.
// Clients and servers must populate every field identically for verification
// to succeed; any field that is forwarded but *not* covered by this struct can
// be tampered with inside the MaxTimestampDrift replay window without
// invalidating the signature.
//
// Version must be set to a known protocol constant (ProtocolV1). It is the
// first field in the canonical string so that a future v2 with different
// structure cannot be confused for v1 output under the same key.
type CanonicalRequest struct {
Version string // e.g. ProtocolV1
Method string // e.g. "GET", "POST"
Host string // e.g. "open.feishu.cn"
PathAndQuery string // e.g. "/open-apis/calendar/v4/events?page_size=50"
BodySHA256 string // hex-encoded SHA-256 of the request body
Timestamp string // Unix epoch seconds string
Identity string // IdentityUser or IdentityBot
AuthHeader string // header the server should inject the real token into
}
// canonicalString joins the fields with newlines. Field order is part of the
// protocol contract — do not reorder without bumping Version.
func (c CanonicalRequest) canonicalString() string {
return strings.Join([]string{
c.Version,
c.Method,
c.Host,
c.PathAndQuery,
c.BodySHA256,
c.Timestamp,
c.Identity,
c.AuthHeader,
}, "\n")
}
// Sign computes the HMAC-SHA256 signature over the canonical request string.
func Sign(key []byte, req CanonicalRequest) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(req.canonicalString()))
return hex.EncodeToString(mac.Sum(nil))
}
// Verify checks that signature matches the HMAC-SHA256 of the canonical
// request and that the timestamp is within MaxTimestampDrift seconds of now.
// Returns nil on success.
func Verify(key []byte, req CanonicalRequest, signature string) error {
ts, err := strconv.ParseInt(req.Timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp %q: %w", req.Timestamp, err)
}
drift := math.Abs(float64(time.Now().Unix() - ts))
if drift > MaxTimestampDrift {
return fmt.Errorf("timestamp drift %.0fs exceeds limit %ds", drift, MaxTimestampDrift)
}
expected := Sign(key, req)
if !hmac.Equal([]byte(expected), []byte(signature)) {
return fmt.Errorf("HMAC signature mismatch")
}
return nil
}
// Timestamp returns the current Unix epoch seconds as a string.
func Timestamp() string {
return strconv.FormatInt(time.Now().Unix(), 10)
}

300
sidecar/hmac_test.go Normal file
View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sidecar
import (
"strconv"
"strings"
"testing"
"time"
)
func TestBodySHA256_Empty(t *testing.T) {
// SHA-256 of empty string is a well-known constant.
want := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
if got := BodySHA256(nil); got != want {
t.Errorf("BodySHA256(nil) = %q, want %q", got, want)
}
if got := BodySHA256([]byte{}); got != want {
t.Errorf("BodySHA256([]byte{}) = %q, want %q", got, want)
}
}
func TestBodySHA256_NonEmpty(t *testing.T) {
got := BodySHA256([]byte(`{"key":"value"}`))
if len(got) != 64 {
t.Errorf("expected 64-char hex string, got %d chars", len(got))
}
}
// canonical is a test helper that builds a fully-populated CanonicalRequest
// with reasonable defaults, so individual tests can override just the field
// they want to tamper with.
func canonical(override func(*CanonicalRequest)) CanonicalRequest {
c := CanonicalRequest{
Version: ProtocolV1,
Method: "POST",
Host: "open.feishu.cn",
PathAndQuery: "/open-apis/im/v1/messages?receive_id_type=chat_id",
BodySHA256: BodySHA256([]byte(`{"content":"hello"}`)),
Timestamp: Timestamp(),
Identity: IdentityUser,
AuthHeader: "Authorization",
}
if override != nil {
override(&c)
}
return c
}
func TestSignAndVerify(t *testing.T) {
key := []byte("test-secret-key-32bytes-long!!!!!")
req := canonical(nil)
sig := Sign(key, req)
if len(sig) != 64 {
t.Fatalf("signature should be 64-char hex, got %d chars", len(sig))
}
// Valid verification
if err := Verify(key, req, sig); err != nil {
t.Fatalf("Verify failed for valid signature: %v", err)
}
// Wrong key
if err := Verify([]byte("wrong-key"), req, sig); err == nil {
t.Error("Verify should fail with wrong key")
}
// Each field must be covered by the signature — tampering with any one
// invalidates it.
fields := map[string]func(*CanonicalRequest){
"version": func(c *CanonicalRequest) { c.Version = "v2" },
"method": func(c *CanonicalRequest) { c.Method = "GET" },
"host": func(c *CanonicalRequest) { c.Host = "evil.com" },
"pathAndQuery": func(c *CanonicalRequest) { c.PathAndQuery = "/steal" },
"bodySHA256": func(c *CanonicalRequest) { c.BodySHA256 = BodySHA256([]byte("tampered")) },
"identity": func(c *CanonicalRequest) { c.Identity = IdentityBot },
"authHeader": func(c *CanonicalRequest) { c.AuthHeader = "Cookie" },
}
for name, mutate := range fields {
t.Run("tamper_"+name, func(t *testing.T) {
tampered := canonical(mutate)
if err := Verify(key, tampered, sig); err == nil {
t.Errorf("Verify should fail when %s is tampered", name)
}
})
}
}
// TestVerify_PrivilegeConfusion proves C1: without identity and authHeader in
// the canonical string, an attacker holding a captured user-signed request
// could replay it as bot (or vice versa) by flipping the header. With both
// fields now covered, such a flip must invalidate the signature.
func TestVerify_PrivilegeConfusion(t *testing.T) {
key := []byte("test-key")
signed := canonical(func(c *CanonicalRequest) { c.Identity = IdentityUser })
sig := Sign(key, signed)
replayed := signed
replayed.Identity = IdentityBot // attacker flips identity
if err := Verify(key, replayed, sig); err == nil {
t.Error("identity flip must invalidate signature")
}
replayed = signed
replayed.AuthHeader = "Cookie" // attacker redirects injection target
if err := Verify(key, replayed, sig); err == nil {
t.Error("auth-header flip must invalidate signature")
}
}
func TestVerify_TimestampDrift(t *testing.T) {
key := []byte("test-key")
// Timestamp too old
oldTs := strconv.FormatInt(time.Now().Unix()-MaxTimestampDrift-10, 10)
oldReq := canonical(func(c *CanonicalRequest) { c.Timestamp = oldTs })
sig := Sign(key, oldReq)
if err := Verify(key, oldReq, sig); err == nil {
t.Error("Verify should reject expired timestamp")
}
// Timestamp too far in future
futureTs := strconv.FormatInt(time.Now().Unix()+MaxTimestampDrift+10, 10)
futureReq := canonical(func(c *CanonicalRequest) { c.Timestamp = futureTs })
sig = Sign(key, futureReq)
if err := Verify(key, futureReq, sig); err == nil {
t.Error("Verify should reject future timestamp")
}
// Invalid timestamp
badTs := canonical(func(c *CanonicalRequest) { c.Timestamp = "not-a-number" })
if err := Verify(key, badTs, "sig"); err == nil {
t.Error("Verify should reject invalid timestamp")
}
}
func TestSignDeterministic(t *testing.T) {
key := []byte("key")
req := canonical(func(c *CanonicalRequest) { c.Timestamp = "12345" })
a, b := Sign(key, req), Sign(key, req)
if a != b {
t.Errorf("Sign should be deterministic: %q vs %q", a, b)
}
}
func TestValidateProxyAddr(t *testing.T) {
valid := []string{
// loopback IPs
"http://127.0.0.1:16384",
"127.0.0.1:16384",
"[::1]:16384",
"http://[::1]:16384",
// recognized same-host aliases
"http://localhost:8080",
"localhost:8080",
"http://host.docker.internal:16384",
"http://host.containers.internal:16384",
"http://host.lima.internal:16384",
"http://gateway.docker.internal:16384",
// trailing slash is tolerated
"http://127.0.0.1:8080/",
}
for _, addr := range valid {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q) unexpected error: %v", addr, err)
}
}
invalid := []string{
"",
"foobar",
"ftp://127.0.0.1:16384",
"http://",
"http://127.0.0.1:16384/some/path",
":16384",
}
for _, addr := range invalid {
if err := ValidateProxyAddr(addr); err == nil {
t.Errorf("ValidateProxyAddr(%q) expected error, got nil", addr)
}
}
}
// TestValidateProxyAddr_HostConstraint pins C2: the sidecar pattern is
// same-machine by definition, so the validator rejects any host that isn't
// loopback or a recognized same-host alias. Tampered /etc/hosts is out of
// scope (attacker already has ambient host access).
func TestValidateProxyAddr_HostConstraint(t *testing.T) {
sameHost := []string{
"http://127.0.0.1:16384",
"http://localhost:8080",
"http://host.docker.internal:16384",
"http://host.containers.internal:16384",
"http://host.lima.internal:16384",
"http://gateway.docker.internal:16384",
"http://[::1]:16384",
// bare form
"127.0.0.1:16384",
"localhost:8080",
"host.docker.internal:16384",
}
for _, addr := range sameHost {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("expected %q to pass as same-host, got: %v", addr, err)
}
}
notSameHost := map[string]string{
// The interesting ones — plausible misconfigurations / attacks
"public DNS name": "http://attacker.com:8080",
"cloud metadata IMDS": "http://169.254.169.254",
"private RFC1918": "http://10.0.0.1:16384",
"other RFC1918": "http://192.168.1.2:16384",
"link-local IPv4": "http://169.254.1.1:16384",
"unspecified IPv4 (0.0.0.0)": "http://0.0.0.0:16384",
"bare public IP": "http://8.8.8.8:16384",
"bare RFC1918": "10.0.0.1:16384",
}
for name, addr := range notSameHost {
t.Run(name, func(t *testing.T) {
err := ValidateProxyAddr(addr)
if err == nil {
t.Fatalf("expected rejection for %q", addr)
}
// Error must name the constraint so users know why.
msg := err.Error()
if !strings.Contains(msg, "loopback") && !strings.Contains(msg, "same-host") {
t.Errorf("error should explain same-host requirement, got: %v", err)
}
})
}
}
// TestValidateProxyAddr_RejectsUserinfo closes the URL-phishing vector
// http://127.0.0.1@attacker.com (where "127.0.0.1" is actually basic-auth
// userinfo and the real host is attacker.com). userinfo has no legitimate
// use in the sidecar protocol.
func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
for _, addr := range []string{
"http://user@127.0.0.1:16384",
"http://user:pass@127.0.0.1:16384",
"http://127.0.0.1@attacker.com:16384",
} {
err := ValidateProxyAddr(addr)
if err == nil {
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
continue
}
// Either "userinfo" (for addresses parsed with user) or the same-host
// message (for e.g. http://127.0.0.1@attacker.com where the REAL
// host parses as attacker.com) is acceptable — both reject the
// phishing attempt.
msg := err.Error()
if !strings.Contains(msg, "userinfo") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
t.Errorf("error should reject userinfo or flag wrong host, got: %v", err)
}
}
}
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
// rejected explicitly (not lumped into a generic "bad scheme" error) because
// the interceptor hardcodes http and would silently downgrade an https URL
// otherwise. The message must mention https so users understand why their
// perfectly-looking config is refused.
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
for _, addr := range []string{
"https://127.0.0.1:16384",
"https://sidecar.corp.internal:443",
} {
err := ValidateProxyAddr(addr)
if err == nil {
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
continue
}
if !strings.Contains(err.Error(), "https") {
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
}
}
}
func TestProxyHost(t *testing.T) {
tests := []struct {
input string
want string
}{
{"http://127.0.0.1:16384", "127.0.0.1:16384"},
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := ProxyHost(tt.input); got != tt.want {
t.Errorf("ProxyHost(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

198
sidecar/protocol.go Normal file
View File

@@ -0,0 +1,198 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sidecar defines the wire protocol shared between the CLI client
// (running inside a sandbox) and the auth sidecar proxy (running in a
// trusted environment). Communication uses plain HTTP.
package sidecar
import (
"fmt"
"net"
"net/url"
"strings"
)
// ProtocolV1 is the wire-protocol version string embedded in every signed
// request. Servers must reject requests whose HeaderProxyVersion is not a
// version they understand. Bump this constant (and update the canonical
// string) for any breaking change to signing inputs.
const ProtocolV1 = "v1"
// Proxy request headers set by the CLI transport interceptor.
const (
// HeaderProxyVersion carries the wire-protocol version (e.g. ProtocolV1).
// Servers must reject requests whose version they do not understand. The
// value is also included in the canonical signing string so that a request
// signed for one version cannot be replayed as another.
HeaderProxyVersion = "X-Lark-Proxy-Version"
// HeaderProxyTarget carries the original request host (e.g. "open.feishu.cn").
HeaderProxyTarget = "X-Lark-Proxy-Target"
// HeaderProxyIdentity carries the resolved identity type ("user" or "bot").
HeaderProxyIdentity = "X-Lark-Proxy-Identity"
// HeaderProxySignature carries the HMAC-SHA256 hex signature.
HeaderProxySignature = "X-Lark-Proxy-Signature"
// HeaderProxyTimestamp carries the Unix epoch seconds string used in signing.
HeaderProxyTimestamp = "X-Lark-Proxy-Timestamp"
// HeaderBodySHA256 carries the hex-encoded SHA-256 digest of the request body.
HeaderBodySHA256 = "X-Lark-Body-SHA256"
// HeaderProxyAuthHeader tells the sidecar which header to inject the real
// token into. Defaults to "Authorization" for standard OpenAPI requests.
// MCP requests use "X-Lark-MCP-UAT" or "X-Lark-MCP-TAT".
HeaderProxyAuthHeader = "X-Lark-Proxy-Auth-Header"
)
// MCP auth headers used by the Lark MCP protocol.
const (
HeaderMCPUAT = "X-Lark-MCP-UAT"
HeaderMCPTAT = "X-Lark-MCP-TAT"
)
// Sentinel token values returned by the noop credential provider.
// These are placeholder strings that flow through the SDK auth pipeline
// but are stripped by the transport interceptor before reaching the sidecar.
const (
SentinelUAT = "sidecar-managed-uat" // User Access Token placeholder
SentinelTAT = "sidecar-managed-tat" // Tenant Access Token placeholder
)
// IdentityUser and IdentityBot are the wire values for HeaderProxyIdentity.
const (
IdentityUser = "user"
IdentityBot = "bot"
)
// MaxTimestampDrift is the maximum allowed difference (in seconds) between
// the request timestamp and the server's current time.
const MaxTimestampDrift = 60
// DefaultListenAddr is the default sidecar listen address (localhost only).
const DefaultListenAddr = "127.0.0.1:16384"
// sameHostAliases names DNS aliases commonly used to reach the host running
// the sandbox across a container / VM boundary. Traffic to these names stays
// on the physical machine (via a virtual bridge), so a plaintext sidecar
// channel still satisfies the sidecar pattern's same-host confidentiality
// requirement. Adding to this list has real security implications — only add
// names that are universally same-host by the runtime's design.
var sameHostAliases = map[string]bool{
"localhost": true, // universal
"host.docker.internal": true, // Docker Desktop (macOS / Windows)
"host.containers.internal": true, // Podman Desktop
"host.lima.internal": true, // Lima / colima / rancher-desktop
"gateway.docker.internal": true, // Docker Desktop alt name
}
// isSameHost returns true when host is either a loopback IP or a recognized
// same-host DNS alias. Does not perform DNS resolution — a tampered /etc/hosts
// that points an alias elsewhere is out of scope (attacker with that access
// already has ambient control of the machine).
func isSameHost(host string) bool {
if sameHostAliases[host] {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
// errNotSameHost is the shared error returned when the sidecar address does
// not resolve to the same physical host as the sandbox. Kept in one place so
// tests can look for a stable marker.
func errNotSameHost(addr string) error {
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
"(127.0.0.1 / ::1) or a recognized same-host alias "+
"(localhost, host.docker.internal, host.containers.internal, "+
"host.lima.internal, gateway.docker.internal). "+
"The sidecar must run on the same physical machine as the sandbox — "+
"cross-machine deployment is not a sidecar and is not supported", addr)
}
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
// Accepted formats:
// - http://host:port
// - host:port (bare address, treated as http)
//
// Host must be loopback or in sameHostAliases. The sidecar pattern is
// inherently same-machine; cross-machine deployment is a different product
// and is not supported by this feature.
//
// https:// is rejected because sidecar is a same-host pattern: loopback
// and virtual same-host bridges don't traverse any untrusted medium, so
// TLS adds no security. Cross-machine deployment is out of scope (see the
// host constraint above), so there is no scenario today where https
// provides a real benefit over http on loopback.
//
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
// does not use basic auth, and the syntactic slot exists only as a phishing
// vector (e.g. http://127.0.0.1@attacker.com).
//
// Returns an error if the value is not a valid proxy address.
func ValidateProxyAddr(addr string) error {
if addr == "" {
return fmt.Errorf("proxy address is empty")
}
// Bare host:port (no scheme) — validate as a net address.
if !strings.Contains(addr, "://") {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
}
if host == "" || port == "" {
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
}
if !isSameHost(host) {
return errNotSameHost(addr)
}
return nil
}
u, err := url.Parse(addr)
if err != nil {
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
}
if u.User != nil {
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
}
if u.Scheme == "https" {
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
"no security; cross-machine deployment is out of scope", addr)
}
if u.Scheme != "http" {
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
}
if u.Host == "" {
return fmt.Errorf("invalid proxy address %q: missing host", addr)
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
}
// u.Hostname() strips the port and unwraps IPv6 brackets.
if !isSameHost(u.Hostname()) {
return errNotSameHost(addr)
}
return nil
}
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
// Returns the host:port portion for URL rewriting.
func ProxyHost(authProxy string) string {
// Strip scheme
host := authProxy
if i := strings.Index(host, "://"); i >= 0 {
host = host[i+3:]
}
// Strip trailing slash
host = strings.TrimRight(host, "/")
return host
}

View File

@@ -0,0 +1,197 @@
# Sidecar Server Reference Implementation
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
This example shows how to implement a sidecar auth proxy server that receives
HMAC-signed requests from lark-cli sandbox clients and forwards them to the
Lark/Feishu API with real credentials injected.
## What this demo shows
- HMAC-SHA256 request verification (timestamp drift, body digest, signature)
- Target host allowlist + https-only target validation (anti-SSRF / anti-downgrade)
- Identity-based token resolution (UAT for user, TAT for bot)
- Auth-header allowlist: real token may only be injected into `Authorization`
/ `X-Lark-MCP-UAT` / `X-Lark-MCP-TAT`, rejecting attempts to smuggle it into
`Cookie`, `User-Agent`, or other intermediate-logged headers
- Audit logging with path ID-segment sanitization and upstream error truncation
- Safe request forwarding (strips client-supplied auth headers)
## What this demo does NOT handle
- **TAT refresh** — the shared `DefaultTokenProvider` caches the TAT via
`sync.Once`, which never refreshes. A long-running server will return an
expired TAT after 2 hours. Production implementations should maintain a
TTL-based cache with early renewal.
- High availability / load balancing / hot key rotation
- TLS termination
- Rate limiting / per-identity quotas
## Both sides need the right build tags
Sidecar is split into **two separate binaries** with **different build tags**:
| Side | Binary | Build tag | How to build |
| --- | --- | --- | --- |
| Sandbox (client) | `lark-cli` | `authsidecar` | `go build -tags authsidecar -o lark-cli .` |
| Trusted (server) | `sidecar-server-demo` | `authsidecar_demo` | `go build -tags authsidecar_demo -o sidecar-server-demo ./sidecar/server-demo/` |
If the sandbox runs a standard `lark-cli` **without** `-tags authsidecar`, the
`LARKSUITE_CLI_AUTH_PROXY` env var is ignored and requests bypass the sidecar
entirely — real credentials (if any) leak to the sandbox.
## Prerequisites
The demo reuses the lark-cli credential pipeline, so the trusted machine must
have an app configured:
```bash
lark-cli config init --new # configure app_id / app_secret (required)
lark-cli auth login # store user refresh_token in keychain
# (only required if sandbox will use --as user)
```
`auth login` is **only required for user identity**. If the server will only
serve bot requests (TAT), `config init` alone is enough because the TAT is
minted from `app_id + app_secret`.
Also, the server process **must not** inherit `LARKSUITE_CLI_AUTH_PROXY` — if
it does, the sidecar credential provider would activate inside the server and
return sentinel tokens instead of real ones. The demo rejects this at startup
with a clear error, but you should make sure to `unset LARKSUITE_CLI_AUTH_PROXY`
in the server shell before launching.
## Run
```bash
./sidecar-server-demo \
--listen 127.0.0.1:16384 \
--key-file <HOME>/.lark-sidecar/proxy.key \
--log-file <HOME>/.lark-sidecar/audit.log
```
### Flags
| Flag | Default | Purpose |
| --- | --- | --- |
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
| `--key-file` | `<HOME>/.lark-sidecar/proxy.key` | Path to write the generated HMAC key (mode 0600) |
| `--log-file` | *(empty, stderr)* | Audit log output path |
| `--profile` | *(empty, active profile)* | lark-cli profile name for credential lookup |
### Startup output
```
Auth sidecar listening on http://127.0.0.1:16384
HMAC key prefix: a3b2c1d4
Full key written to /Users/alice/.lark-sidecar/proxy.key (mode 0600)
Set in sandbox:
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
export LARKSUITE_CLI_PROXY_KEY="<read from /Users/alice/.lark-sidecar/proxy.key>"
export LARKSUITE_CLI_APP_ID="cli_xxx"
export LARKSUITE_CLI_BRAND="feishu"
```
The `key-file` path is printed exactly as passed on the command line (relative
paths stay relative). The `HMAC key prefix` is the first 8 characters for
identification without revealing the full key.
### Sandbox env vars (complete list)
The startup banner only prints the *required* variables. Two more are
optional:
```bash
export LARKSUITE_CLI_AUTH_PROXY="http://..." # required (see constraints below)
export LARKSUITE_CLI_PROXY_KEY="..." # required
export LARKSUITE_CLI_APP_ID="cli_xxx" # required
export LARKSUITE_CLI_BRAND="feishu" # required (feishu | lark)
export LARKSUITE_CLI_DEFAULT_AS="user" # optional: force default identity
export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to one identity
```
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
today because the interceptor does not yet perform TLS; a future PR that
wires up real TLS will relax this.
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
same-host aliases: `localhost`, `host.docker.internal`,
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
The sidecar pattern is inherently same-machine; cross-machine deployment
is a different product (auth broker / STS) with different security
requirements (mTLS, cert rotation, per-client keys) and is not supported
by this feature.
- No path, query, fragment, or `user:pass@` in the URL.
**How auto identity detection works in sidecar mode**: on every invocation the
CLI asks the sidecar to look up the logged-in user's `open_id` via
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
if it fails (trusted side has no valid user login, or the call errors out),
it falls back to `bot`. Setting `LARKSUITE_CLI_DEFAULT_AS=user` lets you
short-circuit this and always default to user regardless of the lookup
result; set it to `bot` for the opposite.
**Note**: `LARKSUITE_CLI_STRICT_MODE` and the server's identity allowlist are
two separate enforcement points:
- `STRICT_MODE` is interpreted locally by the sandbox CLI — it rejects
`--as` values the sandbox itself disallows, before any request goes out.
- The server's allowlist is built from the **trusted-side** config's
`SupportedIdentities` (`sidecar/server-demo/allowlist.go`). The sandbox
cannot override it.
A well-configured deployment aligns both (e.g. both set to `user` when the
app only supports user tokens), but they are computed independently.
### Graceful shutdown
Send `SIGINT` (`Ctrl+C`) or `SIGTERM` to stop the server. The demo drains
in-flight requests with a 5-second timeout before exiting.
## Wire protocol
See the [`sidecar` package on pkg.go.dev](https://pkg.go.dev/github.com/larksuite/cli/sidecar)
for protocol constants, HMAC signing/verification, and address validation utilities.
Headers (client → server):
| Header | Purpose |
| --- | --- |
| `X-Lark-Proxy-Version` | Wire-protocol version (currently `"v1"`). Server rejects unknown values with 400. |
| `X-Lark-Proxy-Target` | Original target **scheme + host only** (e.g. `https://open.feishu.cn`). Must be `https://`; any path/query/fragment/userinfo in this header is rejected. The path and query come from the request line itself; the server reconstructs the upstream URL as `https://<host> + requestURI`. |
| `X-Lark-Proxy-Identity` | `"user"` or `"bot"`. Covered by the signature. |
| `X-Lark-Proxy-Auth-Header` | Which header the server should inject real token into. Covered by the signature. |
| `X-Lark-Proxy-Signature` | hex-encoded HMAC-SHA256 |
| `X-Lark-Proxy-Timestamp` | Unix seconds (drift ≤ 60s) |
| `X-Lark-Body-SHA256` | hex-encoded SHA-256 of the request body |
Signing material (newline-separated, in order):
```text
version
method
host
pathAndQuery
bodySHA256
timestamp
identity
authHeader
```
Every field above is part of the canonical string. In particular, `identity`
and `authHeader` are covered so a captured request cannot be replayed with
its identity flipped (bot↔user) or its auth-header redirected (e.g. into
`Cookie`) inside the 60s drift window.
## Source layout
| File | Purpose |
| --- | --- |
| `main.go` | Entry point: flag parsing, server lifecycle |
| `handler.go` | `proxyHandler.ServeHTTP` — main request flow |
| `forward.go` | Forwarding HTTP client + proxy-header filter |
| `allowlist.go` | Target host / identity allowlists |
| `audit.go` | Log path/error sanitization |
| `handler_test.go` | Unit tests for all of the above |

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/sidecar"
)
// buildAllowedHosts extracts the set of allowed target hostnames from
// multiple brand endpoints so the sidecar can serve both feishu and lark clients.
func buildAllowedHosts(endpoints ...core.Endpoints) map[string]bool {
hosts := make(map[string]bool)
for _, ep := range endpoints {
for _, u := range []string{ep.Open, ep.Accounts, ep.MCP} {
if idx := strings.Index(u, "://"); idx >= 0 {
hosts[u[idx+3:]] = true
}
}
}
return hosts
}
// buildAllowedIdentities returns the set of identities the sidecar is allowed to serve,
// based on the trusted-side strict mode / SupportedIdentities configuration.
func buildAllowedIdentities(cfg *core.CliConfig) map[string]bool {
ids := make(map[string]bool)
switch {
case cfg.SupportedIdentities == 0: // unknown/unset → allow both
ids[sidecar.IdentityUser] = true
ids[sidecar.IdentityBot] = true
case cfg.SupportedIdentities&1 != 0: // SupportsUser bit
ids[sidecar.IdentityUser] = true
}
if cfg.SupportedIdentities == 0 || cfg.SupportedIdentities&2 != 0 { // SupportsBot bit
ids[sidecar.IdentityBot] = true
}
return ids
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import "strings"
// sanitizePath strips query parameters and replaces ID-like path segments
// with ":id" to prevent document tokens, chat IDs, etc. from leaking into logs.
// Example: /open-apis/docx/v1/documents/doxcnXXXX/blocks → /open-apis/docx/v1/documents/:id/blocks
func sanitizePath(pathAndQuery string) string {
// Strip query
path := pathAndQuery
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
// Replace ID-like segments (8+ chars, not a pure API keyword)
parts := strings.Split(path, "/")
for i, p := range parts {
if looksLikeID(p) {
parts[i] = ":id"
}
}
return strings.Join(parts, "/")
}
// looksLikeID returns true if a path segment appears to be a resource identifier
// rather than an API route keyword. Heuristic: 8+ chars and contains a digit.
func looksLikeID(seg string) bool {
if len(seg) < 8 {
return false
}
for _, c := range seg {
if c >= '0' && c <= '9' {
return true
}
}
return false
}
// sanitizeError returns a safe error string for logging, capped at 200 bytes
// to avoid dumping upstream response bodies into audit logs.
func sanitizeError(err error) string {
s := err.Error()
if len(s) > 200 {
return s[:200] + "..."
}
return s
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"fmt"
"net/http"
"time"
"github.com/larksuite/cli/sidecar"
)
// newForwardClient creates an HTTP client for forwarding requests to the
// Lark API. It strips Authorization on cross-host redirects and disables
// proxy to prevent real tokens from leaking through environment proxies.
func newForwardClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil // never proxy the trusted hop
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
}
return nil
},
}
}
// isProxyHeader returns true for headers specific to the sidecar protocol.
func isProxyHeader(key string) bool {
switch http.CanonicalHeaderKey(key) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/sidecar"
)
// proxyHandler handles HTTP requests from sandbox CLI instances.
type proxyHandler struct {
key []byte
cred *credential.CredentialProvider
appID string
brand core.LarkBrand
logger *log.Logger
forwardCl *http.Client
allowedHosts map[string]bool // target host allowlist derived from brand
allowedIDs map[string]bool // identity allowlist derived from strict mode
}
// allowedAuthHeaders lists the only header names the sidecar will inject real
// tokens into. Limiting this prevents a compromised sandbox from signing a
// request with X-Lark-Proxy-Auth-Header: Cookie (or User-Agent /
// X-Forwarded-For / any X-* header) and having the real token smuggled into
// an upstream header that Lark ignores for auth but intermediate logs may
// capture — an indirect exfiltration path.
//
// These three are the only values the CLI interceptor ever emits
// (Authorization for OpenAPI, MCP-UAT/TAT for the MCP protocol), so anything
// else is by definition a misuse.
var allowedAuthHeaders = map[string]bool{
"Authorization": true,
sidecar.HeaderMCPUAT: true, // X-Lark-MCP-UAT
sidecar.HeaderMCPTAT: true, // X-Lark-MCP-TAT
}
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 0. Check protocol version. We reject rather than default so that an
// old client paired with a newer server (or vice versa) fails loudly
// instead of silently producing mismatched signatures.
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return
}
// 1. Verify timestamp
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
if ts == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
return
}
// 2. Read body and verify SHA256
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
r.Body.Close()
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" {
http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest)
return
}
actualSHA := sidecar.BodySHA256(body)
if claimedSHA != actualSHA {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return
}
// 3. Verify HMAC signature
//Enforce scheme=https and reject any path/query embedded in the target.
// The sandbox is untrusted: without this check it could send
// X-Lark-Proxy-Target: http://open.feishu.cn to force the injected real
// token out over cleartext HTTP, exposing it to any on-path attacker
// between the sidecar and upstream.
target := r.Header.Get(sidecar.HeaderProxyTarget)
if target == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest)
return
}
pathAndQuery := r.URL.RequestURI()
targetHost, err := parseTarget(target)
if err != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
// Identity and auth-header must be read before HMAC verification because
// both are covered by the canonical signing string. Defaulting either one
// server-side would let an attacker flip the injected token's identity or
// target header within the replay window without invalidating the sig.
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
if identity == "" {
http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest)
return
}
authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader)
if authHeader == "" {
http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest)
return
}
signature := r.Header.Get(sidecar.HeaderProxySignature)
if err := sidecar.Verify(h.key, sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: pathAndQuery,
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}, signature); err != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
// 4. Validate target host against allowlist
if !h.allowedHosts[targetHost] {
http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost)
return
}
// 5. Validate identity
if !h.allowedIDs[identity] {
http.Error(w, "identity not allowed: "+identity, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity)
return
}
// 5.5 Validate auth-header (required — the client controls this value,
// and without an allowlist a compromised sandbox could direct the real
// token into arbitrary forwarded headers).
if !allowedAuthHeaders[authHeader] {
http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader)
return
}
// 6. Resolve real token
var tokenType credential.TokenType
switch identity {
case sidecar.IdentityUser:
tokenType = credential.TokenTypeUAT
default:
tokenType = credential.TokenTypeTAT
}
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
Type: tokenType,
AppID: h.appID,
})
if err != nil {
http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err))
return
}
// 7. Build forwarding request. Scheme is pinned to https here (not taken
// from the client-supplied target) so any future change to parseTarget
// cannot regress the cleartext-leak protection.
forwardURL := "https://" + targetHost + pathAndQuery
forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to create forward request", http.StatusInternalServerError)
return
}
// Copy non-proxy headers
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
forwardReq.Header.Add(k, v)
}
}
// Strip any client-supplied auth headers. The sidecar is the sole source
// of authentication material on the forwarded request; a client could
// otherwise smuggle an extra Authorization/MCP token alongside the one
// the sidecar injects below.
forwardReq.Header.Del("Authorization")
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
// 8. Inject real token into the header the client committed to in the
// signature. Standard OpenAPI uses "Authorization: Bearer <token>"; MCP
// uses "X-Lark-MCP-UAT: <token>" or "X-Lark-MCP-TAT: <token>".
if authHeader == "Authorization" {
forwardReq.Header.Set("Authorization", "Bearer "+tokenResult.Token)
} else {
forwardReq.Header.Set(authHeader, tokenResult.Token)
}
// 9. Forward request
resp, err := h.forwardCl.Do(forwardReq)
if err != nil {
http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway)
h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
defer resp.Body.Close()
// 10. Copy response back
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
// 11. Audit log
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s",
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond))
}
// parseTarget validates X-Lark-Proxy-Target and returns the host portion for
// HMAC input and allowlist lookup. The target must be "https://<host>" with no
// path, query, fragment, userinfo, or non-https scheme. Rejecting these shapes
// closes a token-leak channel: a compromised sandbox holding PROXY_KEY could
// otherwise request cleartext HTTP forwarding (or inject a path to a different
// endpoint than the allowlist entry implies).
func parseTarget(target string) (host string, err error) {
u, perr := url.Parse(target)
if perr != nil {
return "", fmt.Errorf("parse: %w", perr)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}

View File

@@ -0,0 +1,670 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
type fakeExtProvider struct {
token string
}
func (f *fakeExtProvider) Name() string { return "fake" }
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
return nil, nil
}
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: f.token, Source: "fake"}, nil
}
func discardLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}
func newTestHandler(key []byte) *proxyHandler {
return &proxyHandler{
key: key,
logger: discardLogger(),
forwardCl: &http.Client{},
allowedHosts: map[string]bool{
"open.feishu.cn": true,
"accounts.feishu.cn": true,
"mcp.feishu.cn": true,
},
allowedIDs: map[string]bool{
sidecar.IdentityUser: true,
sidecar.IdentityBot: true,
},
}
}
// signedReq creates a properly signed request for testing handler logic past
// HMAC verification. Identity defaults to bot and auth-header to
// "Authorization"; callers can override by mutating the returned request
// before calling ServeHTTP (and re-signing if they need the signature to
// remain valid after the mutation).
func signedReq(t *testing.T, key []byte, method, target, path string, body []byte) *http.Request {
t.Helper()
targetHost := target
if idx := strings.Index(target, "://"); idx >= 0 {
targetHost = target[idx+3:]
}
bodySHA := sidecar.BodySHA256(body)
ts := sidecar.Timestamp()
identity := sidecar.IdentityBot
authHeader := "Authorization"
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: method,
Host: targetHost,
PathAndQuery: path,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, target)
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
return req
}
// resign recomputes the HMAC signature over the request's current proxy
// headers. Use this in tests that mutate a signed field (Identity,
// AuthHeader, Target host, etc.) after calling signedReq.
func resign(t *testing.T, key []byte, req *http.Request, body []byte) {
t.Helper()
target := req.Header.Get(sidecar.HeaderProxyTarget)
targetHost := target
if idx := strings.Index(target, "://"); idx >= 0 {
targetHost = target[idx+3:]
}
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
Version: req.Header.Get(sidecar.HeaderProxyVersion),
Method: req.Method,
Host: targetHost,
PathAndQuery: req.URL.RequestURI(),
BodySHA256: sidecar.BodySHA256(body),
Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp),
Identity: req.Header.Get(sidecar.HeaderProxyIdentity),
AuthHeader: req.Header.Get(sidecar.HeaderProxyAuthHeader),
})
req.Header.Set(sidecar.HeaderProxySignature, sig)
}
// TestProxyHandler_UnsupportedVersion verifies the handler rejects requests
// whose HeaderProxyVersion is absent or set to an unknown value. Kept in
// front so an old client paired with a newer server (or vice versa) surfaces
// a clear 400 instead of a misleading HMAC mismatch downstream.
func TestProxyHandler_UnsupportedVersion(t *testing.T) {
h := newTestHandler([]byte("key"))
for _, v := range []string{"", "v0", "v2"} {
req := httptest.NewRequest("GET", "/path", nil)
if v != "" {
req.Header.Set(sidecar.HeaderProxyVersion, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("version=%q: expected 400, got %d", v, w.Code)
}
}
}
func TestProxyHandler_MissingTimestamp(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_MissingBodySHA(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_BadHMAC(t *testing.T) {
h := newTestHandler([]byte("real-key"))
bodySHA := sidecar.BodySHA256(nil)
ts := sidecar.Timestamp()
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityBot)
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Authorization")
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
req.Header.Set(sidecar.HeaderProxySignature, "bad-signature")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestProxyHandler_BodySHA256Mismatch(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte("real body")))
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
req.Header.Set(sidecar.HeaderBodySHA256, sidecar.BodySHA256([]byte("different body")))
req.Header.Set(sidecar.HeaderProxySignature, "whatever")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_TargetNotAllowed(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://evil.com", "/steal", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for disallowed host, got %d", w.Code)
}
}
func TestProxyHandler_IdentityNotAllowed(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
// Restrict to bot only
h.allowedIDs = map[string]bool{sidecar.IdentityBot: true}
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
resign(t, key, req, nil) // identity is signed; must re-sign after mutation
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for disallowed identity, got %d", w.Code)
}
}
// TestParseTarget covers the per-shape rejections directly, without the
// surrounding HTTP plumbing.
func TestParseTarget(t *testing.T) {
cases := []struct {
name string
target string
wantErr bool
wantSub string // expected fragment of the error message
}{
{name: "valid https", target: "https://open.feishu.cn", wantErr: false},
{name: "valid https trailing slash", target: "https://open.feishu.cn/", wantErr: false},
{name: "http downgrade", target: "http://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "missing scheme", target: "open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "ftp scheme", target: "ftp://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "empty", target: "", wantErr: true, wantSub: "scheme must be https"},
{name: "empty host", target: "https://", wantErr: true, wantSub: "missing host"},
{name: "with path", target: "https://open.feishu.cn/open-apis", wantErr: true, wantSub: "path not allowed"},
{name: "with query", target: "https://open.feishu.cn?a=1", wantErr: true, wantSub: "query not allowed"},
{name: "with fragment", target: "https://open.feishu.cn#frag", wantErr: true, wantSub: "fragment not allowed"},
{name: "with userinfo", target: "https://attacker:pw@open.feishu.cn", wantErr: true, wantSub: "userinfo not allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
host, err := parseTarget(tc.target)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got host=%q", host)
}
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error %q should contain %q", err.Error(), tc.wantSub)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "open.feishu.cn" {
t.Errorf("host = %q, want %q", host, "open.feishu.cn")
}
})
}
}
// TestProxyHandler_RejectsNonHTTPSTarget verifies end-to-end that a
// compromised sandbox holding a valid PROXY_KEY cannot coerce the sidecar
// into forwarding real tokens over cleartext HTTP or to an unexpected path.
// The check must fire before HMAC verification so that the request is
// rejected even when the signature is technically valid.
func TestProxyHandler_RejectsNonHTTPSTarget(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
cases := []struct {
name string
target string
}{
{"http downgrade", "http://open.feishu.cn"},
{"bare hostname", "open.feishu.cn"},
{"ftp scheme", "ftp://open.feishu.cn"},
{"target with path", "https://open.feishu.cn/open-apis/evil"},
{"target with query", "https://open.feishu.cn?steal=1"},
{"target with userinfo", "https://attacker:pw@open.feishu.cn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Sign with a valid key against the malicious target — proves the
// scheme/shape check is not bypassed by signature legitimacy.
req := signedReq(t, key, "GET", tc.target, "/open-apis/im/v1/chats", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for target %q, got %d (body: %s)", tc.target, w.Code, w.Body.String())
}
})
}
}
// TestProxyHandler_RejectsIdentityReplay locks in C1 end-to-end: a captured
// bot-signed request whose identity header is flipped to user (or vice versa)
// must be rejected at HMAC verification, not silently served with the wrong
// token type. Without identity in the canonical string this returns 200.
func TestProxyHandler_RejectsIdentityReplay(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
// Attacker flips identity without touching signature.
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("identity replay must fail signature verify (got %d, want 401): %s",
w.Code, w.Body.String())
}
}
// TestProxyHandler_RejectsAuthHeaderReplay is the companion: flipping
// X-Lark-Proxy-Auth-Header post-signature must invalidate the signature so
// an attacker cannot redirect the injected token into an unintended header.
func TestProxyHandler_RejectsAuthHeaderReplay(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Cookie")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("auth-header replay must fail signature verify (got %d, want 401): %s",
w.Code, w.Body.String())
}
}
// TestProxyHandler_RejectsAuthHeaderNotInAllowlist pins the auth-header
// allowlist: even a correctly-signed request must be rejected if it asks
// the sidecar to inject the real token into an unintended header (e.g.
// Cookie / User-Agent / X-Forwarded-For). This closes the sidechannel
// where the real token ends up in headers that Lark ignores for auth but
// intermediate logs may capture.
func TestProxyHandler_RejectsAuthHeaderNotInAllowlist(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
for _, bad := range []string{"Cookie", "User-Agent", "X-Forwarded-For", "X-Real-IP", "Set-Cookie"} {
t.Run(bad, func(t *testing.T) {
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, bad)
resign(t, key, req, nil) // auth-header is signed; must re-sign after override
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("authHeader=%q: expected 403, got %d (body: %s)",
bad, w.Code, w.Body.String())
}
})
}
}
// TestProxyHandler_AcceptsAllowedAuthHeaders confirms the three protocol
// header names remain accepted after the allowlist is enforced. Uses
// newTestHandler which has no upstream forwarding set up, so reaching the
// forward step is proof the auth-header check passed.
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
key := []byte("test-key")
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
t.Run(good, func(t *testing.T) {
// Use a handler with a real (fake) credential provider so we can
// distinguish auth-header reject (403) from later failures.
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
nil, nil, nil,
)
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: &http.Client{},
allowedHosts: map[string]bool{"open.feishu.cn": true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
resign(t, key, req, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// Expect NOT 403 "auth-header not allowed" — the request will fail
// at forward (502 because open.feishu.cn isn't reachable without
// an actual upstream in tests), but it must get past our check.
if w.Code == http.StatusForbidden && strings.Contains(w.Body.String(), "auth-header not allowed") {
t.Errorf("authHeader=%q was rejected by allowlist: %s", good, w.Body.String())
}
})
}
}
func TestRun_RejectsSelfProxy(t *testing.T) {
old, had := os.LookupEnv(envvars.CliAuthProxy)
os.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
defer func() {
if had {
os.Setenv(envvars.CliAuthProxy, old)
} else {
os.Unsetenv(envvars.CliAuthProxy)
}
}()
err := run(context.Background(), "127.0.0.1:0", "/tmp/should-not-be-created.key", "", "")
if err == nil {
t.Fatal("expected error when AUTH_PROXY is set")
}
if !strings.Contains(err.Error(), envvars.CliAuthProxy) {
t.Errorf("error should mention %s, got: %v", envvars.CliAuthProxy, err)
}
}
func TestForwardClient_RedirectStripsAuth(t *testing.T) {
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth := r.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization leaked to redirect target: %s", auth)
}
w.WriteHeader(http.StatusOK)
}))
defer redirectTarget.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
}))
defer origin.Close()
client := newForwardClient()
req, _ := http.NewRequest("GET", origin.URL+"/start", nil)
req.Header.Set("Authorization", "Bearer real-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
}
func TestForwardClient_RedirectStripsMCPHeaders(t *testing.T) {
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("X-Lark-MCP-UAT leaked to redirect target: %s", v)
}
if v := r.Header.Get(sidecar.HeaderMCPTAT); v != "" {
t.Errorf("X-Lark-MCP-TAT leaked to redirect target: %s", v)
}
w.WriteHeader(http.StatusOK)
}))
defer redirectTarget.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
}))
defer origin.Close()
client := newForwardClient()
req, _ := http.NewRequest("POST", origin.URL+"/mcp", nil)
req.Header.Set(sidecar.HeaderMCPUAT, "real-uat-token")
req.Header.Set(sidecar.HeaderMCPTAT, "real-tat-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
}
// TestProxyHandler_StripsClientSuppliedAuthHeaders verifies that the sidecar
// is the sole source of auth headers on the forwarded request. A malicious
// sandbox client must not be able to smuggle an Authorization/MCP header that
// rides along with the sidecar-injected real token.
func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
const realToken = "real-tenant-access-token"
// Capture what the upstream receives after sidecar forwarding.
// TLS is required because parseTarget rejects non-https targets.
var captured http.Header
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
// Strip "https://" prefix to get host:port (matches what the handler sees).
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: realToken}},
nil, nil, nil,
)
key := []byte("test-key")
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: upstream.Client(), // trusts the httptest CA
allowedHosts: map[string]bool{upstreamHost: true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
cases := []struct {
name string
proxyAuthHeader string // which header sidecar should inject into
wantInjectedHeader string // the header the real token ends up in
wantInjectedValue string
wantStrippedHeaders []string
}{
{
name: "inject Authorization, strip MCP attacker headers",
proxyAuthHeader: "Authorization",
wantInjectedHeader: "Authorization",
wantInjectedValue: "Bearer " + realToken,
wantStrippedHeaders: []string{sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT},
},
{
name: "inject MCP UAT, strip Authorization attacker header",
proxyAuthHeader: sidecar.HeaderMCPUAT,
wantInjectedHeader: sidecar.HeaderMCPUAT,
wantInjectedValue: realToken,
wantStrippedHeaders: []string{"Authorization", sidecar.HeaderMCPTAT},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
captured = nil
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, tc.proxyAuthHeader)
resign(t, key, req, nil) // auth-header is signed; re-sign after override
// Attacker smuggles all three possible auth headers with bogus values.
req.Header.Set("Authorization", "Bearer attacker-token")
req.Header.Set(sidecar.HeaderMCPUAT, "attacker-uat")
req.Header.Set(sidecar.HeaderMCPTAT, "attacker-tat")
// Non-auth headers should still pass through.
req.Header.Set("X-Custom-Header", "keep-me")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from upstream, got %d; body=%s", w.Code, w.Body.String())
}
if captured == nil {
t.Fatal("upstream handler was not invoked")
}
// Injected header contains the real token (not the attacker value).
if got := captured.Get(tc.wantInjectedHeader); got != tc.wantInjectedValue {
t.Errorf("%s = %q, want %q", tc.wantInjectedHeader, got, tc.wantInjectedValue)
}
// All other auth headers must be stripped.
for _, h := range tc.wantStrippedHeaders {
if got := captured.Get(h); got != "" {
t.Errorf("%s should be stripped, got %q", h, got)
}
}
// Non-auth headers still forwarded.
if got := captured.Get("X-Custom-Header"); got != "keep-me" {
t.Errorf("X-Custom-Header = %q, want %q", got, "keep-me")
}
})
}
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := struct{ Open, Accounts, MCP string }{
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
}
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts
if !hosts["open.feishu.cn"] {
t.Error("expected open.feishu.cn in allowlist")
}
if !hosts["mcp.feishu.cn"] {
t.Error("expected mcp.feishu.cn in allowlist")
}
// lark hosts
if !hosts["open.larksuite.com"] {
t.Error("expected open.larksuite.com in allowlist")
}
if !hosts["mcp.larksuite.com"] {
t.Error("expected mcp.larksuite.com in allowlist")
}
// evil host
if hosts["evil.com"] {
t.Error("evil.com should not be in allowlist")
}
}
func TestSanitizePath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/open-apis/im/v1/messages?receive_id_type=chat_id", "/open-apis/im/v1/messages"},
{"/open-apis/calendar/v4/events", "/open-apis/calendar/v4/events"},
{"/open-apis/docx/v1/documents/doxcnABCD1234/blocks", "/open-apis/docx/v1/documents/:id/blocks"},
{"/open-apis/im/v1/chats/oc_abcdef12345678/members", "/open-apis/im/v1/chats/:id/members"},
{"/path?secret=abc", "/path"},
}
for _, tt := range tests {
if got := sanitizePath(tt.input); got != tt.want {
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestLooksLikeID(t *testing.T) {
tests := []struct {
seg string
want bool
}{
{"doxcnABCD1234", true}, // doc token
{"oc_abcdef12345678", true}, // chat ID
{"v1", false}, // API version
{"messages", false}, // route keyword
{"open-apis", false}, // route prefix
{"ab1", false}, // too short
}
for _, tt := range tests {
if got := looksLikeID(tt.seg); got != tt.want {
t.Errorf("looksLikeID(%q) = %v, want %v", tt.seg, got, tt.want)
}
}
}
func TestSanitizeError(t *testing.T) {
short := fmt.Errorf("short error")
if got := sanitizeError(short); got != "short error" {
t.Errorf("got %q", got)
}
longMsg := make([]byte, 300)
for i := range longMsg {
longMsg[i] = 'x'
}
long := fmt.Errorf("%s", string(longMsg))
got := sanitizeError(long)
if len(got) > 210 {
t.Errorf("expected truncation, got %d chars", len(got))
}
if !bytes.HasSuffix([]byte(got), []byte("...")) {
t.Errorf("expected '...' suffix, got %q", got[len(got)-10:])
}
}

167
sidecar/server-demo/main.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
// Command sidecar-server-demo is a reference implementation of a sidecar
// auth proxy server. It is NOT production-ready — integrators should
// implement their own server conforming to the wire protocol defined in
// github.com/larksuite/cli/sidecar.
//
// The demo reuses the lark-cli credential pipeline (keychain + config) to
// resolve real tokens, so it only works on a machine that has been
// configured with `lark-cli auth login`.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/sidecar"
)
func main() {
listen := flag.String("listen", sidecar.DefaultListenAddr, "listen address (host:port)")
keyFile := flag.String("key-file", defaultKeyFile(), "path to write the HMAC key")
logFile := flag.String("log-file", "", "audit log file (stderr if empty)")
profile := flag.String("profile", "", "lark-cli profile name (empty = active profile)")
flag.Parse()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := run(ctx, *listen, *keyFile, *logFile, *profile); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func defaultKeyFile() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".lark-sidecar", "proxy.key")
}
return "/tmp/lark-sidecar/proxy.key"
}
func run(ctx context.Context, listen, keyFile, logFile, profile string) error {
// Reject self-proxy: if this process inherited AUTH_PROXY, the sidecar
// credential provider would activate and return sentinel tokens instead
// of real ones, breaking the "trusted side holds real credentials" premise.
if v := os.Getenv(envvars.CliAuthProxy); v != "" {
return fmt.Errorf("%s is set in this environment (%s); unset it before starting the sidecar server", envvars.CliAuthProxy, v)
}
if listen == "" {
return fmt.Errorf("invalid --listen address: empty")
}
// Generate HMAC key (32 bytes = 256 bits) and write it to disk (0600).
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return fmt.Errorf("failed to generate HMAC key: %v", err)
}
keyHex := hex.EncodeToString(keyBytes)
keyDir := filepath.Dir(keyFile)
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
return fmt.Errorf("failed to create key directory: %v", err)
}
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
return fmt.Errorf("failed to write key file: %v", err)
}
// Audit logger: file or stderr.
var auditLogger *log.Logger
if logFile != "" {
f, err := vfs.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open log file: %v", err)
}
defer f.Close()
auditLogger = log.New(f, "", log.LstdFlags)
} else {
auditLogger = log.New(os.Stderr, "[audit] ", log.LstdFlags)
}
// Reuse the lark-cli credential pipeline. A production implementation
// would likely source credentials from a secrets manager instead.
factory := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
cfg, err := factory.Config()
if err != nil {
return fmt.Errorf("failed to load config: %v", err)
}
listener, err := net.Listen("tcp", listen)
if err != nil {
return fmt.Errorf("failed to listen on %s: %v", listen, err)
}
defer listener.Close()
allowedHosts := buildAllowedHosts(
core.ResolveEndpoints(core.BrandFeishu),
core.ResolveEndpoints(core.BrandLark),
)
allowedIDs := buildAllowedIdentities(cfg)
handler := &proxyHandler{
key: []byte(keyHex),
cred: factory.Credential,
appID: cfg.AppID,
brand: cfg.Brand,
logger: auditLogger,
forwardCl: newForwardClient(),
allowedHosts: allowedHosts,
allowedIDs: allowedIDs,
}
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
<-ctx.Done()
auditLogger.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
auditLogger.Printf("shutdown error: %v", err)
}
}()
keyPrefix := keyHex
if len(keyPrefix) > 8 {
keyPrefix = keyPrefix[:8]
}
proxyURL := "http://" + listen
fmt.Fprintf(os.Stderr, "Auth sidecar listening on %s\n", proxyURL)
fmt.Fprintf(os.Stderr, "HMAC key prefix: %s\n", keyPrefix)
fmt.Fprintf(os.Stderr, "Full key written to %s (mode 0600)\n", keyFile)
fmt.Fprintf(os.Stderr, "\nSet in sandbox:\n")
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAuthProxy, proxyURL)
fmt.Fprintf(os.Stderr, " export %s=\"<read from %s>\"\n", envvars.CliProxyKey, keyFile)
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAppID, cfg.AppID)
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliBrand, string(cfg.Brand))
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("sidecar server exited unexpectedly: %v", err)
}
return nil
}

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

@@ -26,9 +26,11 @@ lark-cli approval <resource> <method> [flags] # 调用 API
- `get` — 获取单个审批实例详情
- `cancel` — 撤回审批实例
- `cc` — 抄送审批实例
- `initiated` — 查询用户的已发起列表
### tasks
- `remind` — 催办审批人
- `approve` — 同意审批任务
- `reject` — 拒绝审批任务
- `transfer` — 转交审批任务
@@ -41,6 +43,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
| `instances.get` | `approval:instance:read` |
| `instances.cancel` | `approval:instance:write` |
| `instances.cc` | `approval:instance:write` |
| `instances.initiated` | `approval:instance:read` |
| `tasks.remind` | `approval:instance:write` |
| `tasks.approve` | `approval:task:write` |
| `tasks.reject` | `approval:task:write` |
| `tasks.transfer` | `approval:task:write` |

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

@@ -193,7 +193,7 @@
| 字段 | 类型 | 说明 |
|------|------|----------------------------|
| `allow_edit` | bool | 可新增、删除、修改视图,未提及默认`false` |
| `allow_edit` | bool | 可新增、删除、修改视图;表权限为 `edit` 时默认为 `true`,表权限为 `read_only` 或用户明确限制时`false` |
| `visibility` | object | 可见的视图配置 |
| `visibility.all_visible` | bool | 是否全部可见 |
| `visibility.visible_views` | []string | 可见视图名称 列表 |
@@ -203,7 +203,7 @@
输出 `view_rule` 时,**必须**使用以下完整结构,根据场景选择对应模板:
```json
// 情况 A用户要求可编辑/新增/删除视图 → allow_edit 为 true
// 情况 A表权限为 edit 且用户未明确限制 → allow_edit 默认为 true,全部可见
{
"view_rule": {
"allow_edit": true,
@@ -213,7 +213,7 @@
}
}
// 情况 B用户未提及具体视图,未要求编辑视图 → 全部可见、不可编辑
// 情况 B表权限为 read_only或用户明确说不可编辑视图 → 全部可见、不可编辑
{
"view_rule": {
"allow_edit": false,
@@ -223,10 +223,10 @@
}
}
// 情况 C用户提及了具体视图 → 仅指定视图可见
// 情况 C用户提及了具体视图 → 仅指定视图可见allow_edit 仍按 A/B 规则判断)
{
"view_rule": {
"allow_edit": false,
"allow_edit": true,
"visibility": {
"all_visible": false,
"visible_views": ["表格视图", "看板视图"]
@@ -415,7 +415,15 @@
| 仪表盘访问 | 不配置 | 用户明确提及该仪表盘 |
| `base_rule_map.copy` | `false` | 用户明确要求"允许复制" |
| `base_rule_map.download` | `false` | 用户明确要求"允许下载/打印/副本" |
| `record_operations` 中的 `delete` | 不包含 | 用户明确说"允许删除"或使用强语义("完全管理""可删改" |
### 默认开启项(条件性)
以下能力在特定条件下**默认开启**,用户明确限制时才排除:
| 能力 | 默认值 | 排除条件 |
|------|--------|----------|
| `record_operations` 中的 `delete` | **包含**`perm = edit` 时) | 用户明确限制时才排除 |
| `view_rule.allow_edit` | **`true`**`perm = edit` 时) | 用户明确限制"不可编辑视图"或 `perm = read_only` 时设为 `false` |
---
@@ -436,7 +444,7 @@
### 记录操作默认策略
**注意**:
- 用户未提及时,默认包含 `add`,默认不包含 `delete`
- 用户未提及时,表权限为 `edit` 时默认同时包含 `add``delete`,默认不包含 `delete` 的情况仅适用于用户明确限制操作的场景
- 阅读范围默认对齐编辑范围:用户仅描述可编辑范围、未说明阅读范围时,可阅读范围与可编辑范围保持一致,不主动扩大
- 当可读范围与可编辑范围一致时,**不得**生成 `read_filter_rule_group`;应设置 `other_record_all_read = false``read_filter_rule_group = null`
@@ -475,7 +483,7 @@
1. **先判断用户是否提及了具体视图名称**(如"看板视图可见""甘特图不可编辑"等)
- **是** → `all_visible = false``visible_views` 仅包含用户明确提及为"可见"的视图名称(非 viewID未提及的视图视为不可见
- **否**(用户完全未提及任何视图)→ `all_visible = true`
2. `allow_edit` 默认为 `false`仅当用户明确要求"可编辑视图""可新增/删除视图""可管理视图"时才设为 `true`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A
2. `allow_edit` 在表权限为 `edit` 时**默认为 `true`**仅当用户明确限制"不可编辑视图"时才设为 `false`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A
3. `all_visible``false` 时,`visible_views` **不可为空**,必须至少包含一个视图
**❌ 常见错误 — 缺少 `visibility` 字段:**

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

Some files were not shown because too many files have changed in this diff Show More