Compare commits

..

54 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
liangshuo-1
c442fa27d1 chore: cut v1.0.13 with reviewed release notes (#519)
Change-Id: If9a08002588cc9ae96280bdd8bbc4a05ba0f92a1
2026-04-16 21:09:55 +08:00
ILUO
35a8288baf feat: default skip_task_detail in docs +fetch (#471) 2026-04-16 19:15:57 +08:00
tuxedomm
79379fbc6f fix(im): preserve original URL filename for uploaded file messages (#514)
mediaBuffer.FileName() returned a hardcoded "media"+ext, so IM file
messages sent via URL displayed generic names like "media.pdf" instead
of the filename parsed from the URL. This regressed the pre-refactor
tempfile path which at least carried a unique basename.

Store fileNameFromURL(rawURL) on the buffer and return it from
FileName(). Split newMediaBuffer so the URL-to-filename wiring is
reachable from tests without going through the hardened download
transport.

Also lock in that the local upload branch keeps filepath.Base(filePath)
as file_name, so the URL fix cannot silently regress the local branch
later.

Change-Id: I729b217e9dc9237aeb89c2b89df86a37ad64a840
2026-04-16 19:01:21 +08:00
liangshuo-1
d0ab8ee7dc ci: consolidate workflows into layered CI pyramid with results gate (#510)
* ci: consolidate 6 workflows into layered CI pyramid with results gate

Merge tests.yml, lint.yml, coverage.yml, cli-e2e.yml, gitleaks.yml,
and license-header.yml into a single ci.yml with fail-fast layering:

- L1 fast-gate: build, vet, gofmt, go mod tidy
- L2 quality: unit-test, lint, coverage (40% threshold + Codecov), deadcode (incremental)
- L3 e2e: dry-run (no secrets) + live (with secrets, fork-skip)
- L4 security: gitleaks, govulncheck, go-licenses, license-header

Results gate aggregates all jobs as the single required check for
branch protection.

Also adds:
- arch-audit.yml: weekly cron for dead code, complexity, deps, E2E gaps
- .golangci.yml: depguard shortcuts-no-raw-http, forbidigo fmt.Print/log.Fatal
- AGENTS.md: E2E testing conventions, updated pre-PR checks

Change-Id: I2e21067a9e9e12d366d1b1a092227e9f7d60fe41
2026-04-16 18:16:31 +08:00
zhouyue-bytedance
1608f95632 refactor: 删除第6、7章(参考文档和命令分组) (#500)
- 删除第6章:参考文档列表(重复了第2章的 reference 信息)
- 删除第7章:命令分组表(重复了第2章的模块导航)

这两章是静态索引,维护成本高且容易过时。第2章已提供完整的导航和 reference 链接。
2026-04-16 17:20:34 +08:00
zhouyue-bytedance
e10bf8eca2 fix: 统一 record 批量写入限制为 200 条,移除'建议'改为强制要求 (#499)
- 第106行:批量单次改为 200 条(与 lark-base-record-batch-create.md 对齐)
- 第290行:批量写入改为 200 条(与 API 限制对齐)
- 第291行:连续写入改为'必须串行'(强制要求,非建议)
- 第314行:错误代码 1254104 改为 200 条限制

修复 SKILL.md 与 reference 文档的数字不一致问题
2026-04-16 15:41:26 +08:00
syh-cpdsss
c1d6042552 fix: add atomic overwrite for whiteboard +update (#483) 2026-04-16 14:06:36 +08:00
chenxingtong-bytedance
656c16a47f feat(im): support user access token upload file/media/audio/image and send the resource message (#474)
The /open-apis/im/v1/images and /open-apis/im/v1/files APIs now support User Access Token (UAT) in addition to Tenant Access Token (TAT). Previously the upload helpers forced bot identity unconditionally; this PR aligns them with the surrounding shortcut's --as flag so uploads and sends share the same identity.

Change-Id: I3d7fd528dd30fef9aea2d88100ceb03db4c7c3ac
2026-04-16 14:04:25 +08:00
wittam-01
9dfaff4664 feat: add drive create-folder shortcut and wiki node auto-grant (#470)
Change-Id: I1acd001a1d4616bc5a957cad437e5aa4f1afeb51
2026-04-16 11:19:19 +08:00
liangshuo-1
f0e724cbd4 chore: cut v1.0.12 with reviewed release notes (#493)
This release prep captures the version bump and changelog entry for v1.0.12 without pulling unrelated workspace edits into the release branch.

Change-Id: Ib343337c4851b7cc15a52dd0068795a92092b781
Constraint: Keep the release PR scoped to package version and changelog only
Rejected: Include .gitignore and local workspace files | unrelated to this release PR
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep release notes aligned with shipped changes only; exclude reverted work from summaries
Tested: make unit-test
Tested: go mod tidy
Tested: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
Not-tested: Manual tag/release publishing flow
2026-04-15 21:29:44 +08:00
chanthuang
03ba542a60 Revert "feat: mail support scheduled send (#449)" (#492)
This reverts commit 44e7b5b477.

Change-Id: I0b0c6454cf5ea4c15169a3c683b91795ef880478
2026-04-15 21:04:27 +08:00
chanthuang
5fa68ccaa0 feat(mail): add email signature support (#485)
* feat(mail): add signature foundation, draft exports, and +signature shortcut

- Add signature data model, API provider, and template variable
  interpolation with tests (shortcuts/mail/signature/)
- Export signature-related symbols from draft package (SignatureWrapperClass,
  BuildSignatureHTML, FindMatchingCloseDiv, SplitAtQuote, RemoveSignatureHTML,
  SignatureSpacing, SignatureImage) for use by compose shortcuts
- Add +signature shortcut for listing and viewing email signatures
- Add signature reference documentation

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

* feat(mail): add --signature-id to all compose shortcuts

- Add --signature-id flag to +draft-create, +send, +reply, +reply-all,
  +forward for inserting a signature into the email body
- Add signature image download with SSRF protection (https enforcement,
  no token leak, context timeout, size limit)
- Add signature HTML insertion with quote-aware placement
- Update compose shortcut reference docs

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

* feat(mail): add signature insert/remove ops for +draft-edit

- Add insert_signature and remove_signature patch operations with
  old-signature MIME cleanup and case-insensitive CID matching
- Expose signature ops in supported_ops flat list
- Update SKILL.md and draft-edit reference docs

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

* test(mail): add unit tests for signature patch operations

Test insert_signature and remove_signature ops:
- Insert into basic HTML body
- Insert before quote block (reply/forward)
- Replace existing signature
- Error on plain-text-only draft
- Remove existing signature
- Error when no signature present

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

* fix(mail): address reviewer findings on signature PR

- Remove --device flag and device field from docs (not exposed in CLI)
- Fix signature interpolation to match --from alias address in send_as
  list, instead of always using the primary mailbox address
- Update lark-mail-signature.md reference doc

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

* fix(mail): resolve lint issues — remove unused code, fix gofmt

- Remove unused cidSrcRe, collectSignatureCIDs, isCIDReferencedInHTML
  from signature_html.go (CID logic lives in draft/patch.go)
- Remove unused strings import
- Run gofmt on all affected files

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

* fix(mail): use draft From address for signature interpolation in +draft-edit

Moved signature resolution after draft fetch+parse so insert_signature
reads the From header from the existing draft. This ensures alias and
shared-mailbox senders get correct template variable values (B-NAME,
B-ENTERPRISE-EMAIL) instead of falling back to the primary address.

Change-Id: I917016b17176090124814f30e8e15c67f1604de0
Co-Authored-By: AI
2026-04-15 17:44:59 +08:00
mazhe-nerd
1583af7fc0 feat: 一键安装并配置 (#464) 2026-04-15 17:44:29 +08:00
feng zhi hao
44e7b5b477 feat: mail support scheduled send (#449)
feat: mail support scheduled send
2026-04-15 14:11:19 +08:00
chanthuang
66ec27f6e1 feat(mail): support recipient search (#437)
* feat(mail): add contact search workflow and multi_entity search API

- Add recipient search workflow to mail skill template (search by name,
  email keyword, or group name with rich result display)
- Regenerate SKILL.md with multi_entity.search command

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

* fix: require user confirmation for all contact search results

multi_entity.search is a fuzzy keyword search — a single result does
not guarantee an exact match (e.g. searching "张三" may only return
"张三丰"). Always show candidates for user confirmation before using
the email address in compose parameters.

Change-Id: I447c54cd59b06a88c5d6806bfe76f0adfdceb1ce
Co-Authored-By: AI
2026-04-15 12:33:50 +08:00
chanthuang
162c25527b feat(mail): support recall sent email (#481)
- Add buildSendResult helper that includes recall_available/recall_tip
  when backend returns recall_status in send response
- Update +send, +reply, +reply-all, +forward to use buildSendResult
- Add "Recall Email" section to mail skill template with recall and
  get_recall_detail command examples
- Regenerate SKILL.md

Change-Id: I44317ead8f8a65db81e874cfc3529ffeb21e1384
Co-Authored-By: AI
2026-04-15 12:31:25 +08:00
calendar-assistant
0c7a930fc3 docs: route past meeting queries to lark-vc (#482)
Change-Id: Ia39721ba7b72e08f29422354eb2c82c89c5b81b0
2026-04-15 11:53:53 +08:00
ViperCai
ec9e67c21a feat(slides): add image upload via +media-upload and @path placeholders in +create (#450)
- New `slides +media-upload` shortcut: upload a local image to a slides
  presentation and return the file_token for use in <img src="...">.
- `slides +create --slides` now supports `@./path.png` placeholders that
  are auto-uploaded and replaced with file_tokens.
- Reject images >20 MB (multipart upload not supported for slide_file).
- Support wiki URL resolution for --presentation flag.
2026-04-15 11:44:11 +08:00
zhaoleibd
74e4a97f52 docs(lark-vc): clarify historical date search in skill description (#480)
Explicitly mention historical dates in the description of lark-vc skill to improve query matching for past meetings.

Change-Id: I796382793bb5d910924fac450e5315645ce543d4
2026-04-15 11:23:50 +08:00
liangshuo-1
fe4123436f chore: prepare v1.0.11 release metadata (#472)
Update the package version and changelog entry so the release branch matches the v1.0.11 changes already queued after v1.0.10.

This keeps the published package version and human-readable release notes aligned without pulling unrelated local workspace changes into the release PR.

Change-Id: Ia937651001e0057df4fe82bd11705c52d343f9a9
2026-04-14 20:08:57 +08:00
kongenpei
052e2112bf fix: validate base shortcut JSON object inputs (#458)
* fix: validate base shortcut JSON object inputs

* fix: reject null in base JSON object parser

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-14 19:30:23 +08:00
caojie0621
76a834e928 feat(sheets): add dropdown shortcuts and formula reference docs (#461)
Implement +set-dropdown, +update-dropdown, +get-dropdown, and
+delete-dropdown shortcuts wrapping the v2 dataValidation API.
This resolves the issue where multipleValue writes silently
became plain text because the prerequisite dropdown configuration
step was not exposed as a CLI command.

Also add lark-sheets-formula.md reference for Lark-specific formula
rules (ARRAYFORMULA, native array functions, date diff, etc.) and
update the dropdown limitation note in SKILL.md to link to the new
+set-dropdown shortcut.
2026-04-14 18:48:07 +08:00
ILUO
20761fa56a feat(task): add task shortcuts with skill docs and tests (#377)
* feat(task): add task shortcuts with skill docs and tests

* docs(task): document task event payload shape

* refactor(task): remove unused buildUserIDs helper

* fix(task): handle api error codes in set-ancestor

* docs(task): clarify get-related-tasks page-token unit

* feat(task): support bot identity for subscribe-event

* docs(task): clarify bot subscribe-event scope

* docs(task): clarify related-task pagination semantics

* docs(task): add BOE selftest report (boe_task_tasklist_oapi_support)

* docs(task): prefer related-task shortcuts over search for scoped queries

* docs(task): clarify tasklist search routing

* docs(task): route keywordless tasklist queries to list API

* docs(task): refine search routing heuristics

* feat(event): include task user-access updates in catch-all subscribe

* docs(task): remove auth status --json guidance
2026-04-14 17:24:38 +08:00
mazhe-nerd
2a301246f9 feat: skip auth check (#451)
The secondary confirmation step in the interactive login process has been removed (Phase 2: After the user selects the complete domain name, permission level, and scope, they no longer need to confirm "authorize" again and can directly proceed to the authorization process).
2026-04-14 11:38:39 +08:00
Schumi Lin
abc374f1a3 docs(readme): add Attendance to Features table (#460)
* docs(readme): add lark-attendance to Agent Skills table and update counts

- Add lark-attendance to Agent Skills table in both EN and ZH READMEs
- Add Attendance to ZH Features table
- Update skill count 21 → 22 and domain count 13 → 14
2026-04-14 10:55:33 +08:00
caojie0621
2910cde73a feat(sheets): add value format documentation for formula and special types (#456)
Document the correct object format for writing formulas, URLs with
text, mentions, and dropdown lists via --values parameter. Add
examples contrasting correct object format vs incorrect plain string.
2026-04-14 00:07:45 +08:00
liangshuo-1
7fdc162ff7 chore: bump version to v1.0.10 and update changelog (#457)
Change-Id: I6f8f6b474e2bcedec4646c69b35235c52906c74e
2026-04-13 22:58:20 +08:00
306 changed files with 23305 additions and 2116 deletions

View File

@@ -6,3 +6,6 @@ coverage:
patch:
default:
target: 60%
github_checks:
annotations: true

116
.github/workflows/arch-audit.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Architecture Audit
on:
schedule:
- cron: '0 9 * * 1' # Monday 09:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code detection
run: |
echo "## Dead Code" >> report.md
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
count=$(wc -l < deadcode.txt | tr -d ' ')
echo "Found **$count** unreachable functions" >> report.md
echo '```' >> report.md
cat deadcode.txt >> report.md
echo '```' >> report.md
- name: Package complexity
run: |
echo "## Package Complexity" >> report.md
echo "" >> report.md
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
echo "" >> report.md
echo "| Package | Files | Lines | Deps |" >> report.md
echo "|---------|-------|-------|------|" >> report.md
found=0
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
dir=$(go list -f '{{.Dir}}' "$pkg")
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
deps=$(go list -f '{{len .Imports}}' "$pkg")
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "| _(none)_ | | | |" >> report.md
fi
- name: Dependency freshness
run: |
echo "## Outdated Dependencies" >> report.md
echo '```' >> report.md
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md
echo "" >> report.md
echo "Shortcut domains without E2E tests:" >> report.md
echo "" >> report.md
found=0
for domain in $(ls -d shortcuts/*/); do
name=$(basename "$domain")
if [ "$name" = "common" ]; then continue; fi
if [ ! -d "tests/cli_e2e/$name" ]; then
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "All shortcut domains have E2E test directories." >> report.md
fi
- name: Coverage
run: |
echo "## Coverage" >> report.md
packages=$(go list ./... | grep -v 'tests/cli_e2e')
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
echo "Current total coverage: **${total:-n/a}**" >> report.md
- name: Publish report
run: |
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat report.md >> $GITHUB_STEP_SUMMARY
- name: Upload report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: arch-audit-${{ github.run_number }}
path: report.md
retention-days: 90

334
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,334 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
fast-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
exit 1
fi
- name: Check go.mod tidiness
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
lint:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
coverage:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
exit 1
fi
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
deadcode:
needs: fast-gate
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code check (incremental)
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 -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 -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"
git worktree remove -f /tmp/dc-base 2>/dev/null || true
exit 0
}
git worktree remove -f /tmp/dc-base
# Only new dead code blocks the PR
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
if [ -s /tmp/dc-new.txt ]; then
echo "::group::New dead code"
cat /tmp/dc-new.txt
echo "::endgroup::"
echo "::error::New dead code detected — remove unreachable functions before merging"
exit 1
fi
echo "No new dead code introduced"
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Run dry-run E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
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
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Gitleaks
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
- name: govulncheck
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
license-header:
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
run: |
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi
done
if [ "$FAILED" = "1" ]; then
echo ""
echo "::error::One or more CI jobs failed — see table above"
exit 1
fi

View File

@@ -1,83 +0,0 @@
name: CLI E2E Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
jobs:
cli-e2e:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all

View File

@@ -1,58 +0,0 @@
name: Coverage
on:
push:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
pull_request:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
permissions:
contents: read
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Generate coverage report
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,28 +0,0 @@
name: Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
gitleaks:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
# GITHUB_TOKEN is provided automatically by GitHub Actions.
# GITLEAKS_KEY must be configured as a repository secret.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}

View File

@@ -1,26 +0,0 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

View File

@@ -1,60 +0,0 @@
name: Lint
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
permissions:
contents: read
jobs:
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run govulncheck
continue-on-error: true # informational until Go version is upgraded
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown

View File

@@ -1,43 +0,0 @@
name: Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
- name: Build
run: go build -v ./...

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

@@ -70,6 +70,14 @@ linters:
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
shortcuts-no-raw-http:
files:
- "**/shortcuts/**"
deny:
- pkg: "net/http"
desc: >-
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
The client layer handles auth, headers, and error normalization.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
@@ -100,6 +108,16 @@ linters:
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── output: shortcuts must use ctx.Out() ──
- pattern: fmt\.Print(f|ln)?\b
msg: >-
use ctx.Out() or ctx.OutFormat() for structured JSON output.
fmt.Print* bypasses the output envelope and breaks --jq/--format.
# ── logging: shortcuts must return errors, not log.Fatal ──
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
msg: >-
use structured error return, not log.Fatal/Panic.
Shortcuts must return errors to the framework for proper exit code handling.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-

View File

@@ -18,9 +18,11 @@ make test # Full: vet + unit + integration
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go mod tidy` — must not change `go.mod`/`go.sum`
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
2. `go vet ./...`
3. `gofmt -l .` — must produce no output
4. `go mod tidy` — must not change `go.mod`/`go.sum`
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
## Commit & PR
@@ -76,3 +78,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
### E2E Testing
**Dry-run E2E (required for every shortcut change)**
- Validates request structure without calling real APIs
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
- No secrets needed — runs on fork PRs
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
**Live E2E (required for new flows or behavior changes)**
- Validates real API round-trips
- Place in `tests/cli_e2e/<domain>/`
- Must be self-contained: create -> use -> cleanup
- Needs bot credentials (CI secrets, skipped on fork PRs)
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
| Change | Dry-run E2E | Live E2E |
|--------|:-----------:|:--------:|
| New shortcut | Required | Required |
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |

View File

@@ -2,6 +2,98 @@
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
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
### Bug Fixes
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
### Documentation
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
### CI
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
## [v1.0.12] - 2026-04-15
### Features
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
- **mail**: Return recall hints for sent emails when recall is available (#481)
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
### Documentation
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
## [v1.0.11] - 2026-04-14
### Features
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
- Streamline interactive login by removing the extra auth confirmation step (#451)
### Bug Fixes
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
### Documentation
- **sheets**: Document value formats for formulas and special field types (#456)
- **readme**: Add Attendance to the features table (#460)
## [v1.0.10] - 2026-04-13
### Features
@@ -328,6 +420,11 @@ 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
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 21 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -30,13 +30,15 @@ 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 |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 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
@@ -149,6 +151,7 @@ lark-cli auth status
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-attendance` | Query personal attendance check-in records |
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -36,7 +36,9 @@
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐和指标 |
## 安装与快速开始
@@ -150,6 +152,7 @@ lark-cli auth status
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-attendance` | 查询个人考勤打卡记录 |
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |

View File

@@ -184,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
// Phase 2: confirmation
var confirmed bool
form2 := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(msg.ConfirmAuth).
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if !confirmed {
return nil, output.ErrBare(1)
}
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,

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

@@ -10,12 +10,12 @@ import (
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
@@ -29,11 +29,11 @@ type initMsg struct {
}
var initMsgZh = &initMsg{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用:\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
@@ -46,11 +46,11 @@ var initMsgZh = &initMsg{
}
var initMsgEn = &initMsg{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",

View File

@@ -48,12 +48,12 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,

View File

@@ -101,16 +101,16 @@ type ServiceMethodOptions struct {
SchemaPath string
// Flags
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
}

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

View File

@@ -146,11 +146,20 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
// RunSkillsUpdate installs skills, trying the .well-known source first and
// falling back to the GitHub repo on failure or timeout.
func (u *Updater) RunSkillsUpdate() *NpmResult {
if u.SkillsUpdateOverride != nil {
return u.SkillsUpdateOverride()
}
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -159,7 +168,7 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import "testing"
func TestTruncateStr(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate", "hello world", 5, "hello"},
{"empty", "", 5, ""},
{"zero limit", "hello", 0, ""},
{"CJK characters", "你好世界测试", 4, "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStr(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}
func TestTruncateStrWithEllipsis(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate with ellipsis", "hello world", 8, "hello..."},
{"limit less than 3", "hello", 2, "he"},
{"limit equals 3", "hello world", 3, "..."},
{"empty", "", 5, ""},
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}

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

84
package-lock.json generated Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"cpu": [
"x64",
"arm64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"@clack/prompts": "^1.2.0"
},
"bin": {
"lark-cli": "scripts/run.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@clack/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.2.0",
"fast-string-width": "^1.1.0",
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/fast-string-truncated-width": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^1.2.0"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^1.1.0"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.10",
"version": "1.0.15",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
@@ -27,7 +27,11 @@
"license": "MIT",
"files": [
"scripts/install.js",
"scripts/install-wizard.js",
"scripts/run.js",
"CHANGELOG.md"
]
],
"dependencies": {
"@clack/prompts": "^1.2.0"
}
}

372
scripts/install-wizard.js Normal file
View File

@@ -0,0 +1,372 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const isWindows = process.platform === "win32";
// ---------------------------------------------------------------------------
// i18n
// ---------------------------------------------------------------------------
const messages = {
zh: {
setup: "正在设置 Feishu/Lark CLI...",
step1: "正在安装 %s...",
step1Upgrade: "正在升级 %s (v%s → v%s)...",
step1Skip: "已安装 (v%s),跳过",
step1Done: "已全局安装",
step1Upgraded: "已升级到 v%s",
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
step2: "安装 AI Skills",
step2Skip: "已安装,跳过",
step2Spinner: "正在安装 Skills...",
step2Done: "Skills 已安装",
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
step3: "正在配置应用...",
step3NotFound: "未找到 lark-cli终止",
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
step3Skip: "跳过应用配置",
step3Done: "应用已配置",
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
},
en: {
setup: "Setting up Feishu/Lark CLI...",
step1: "Installing %s globally...",
step1Upgrade: "Upgrading %s (v%s → v%s)...",
step1Skip: "Already installed (v%s). Skipped",
step1Done: "Installed globally",
step1Upgraded: "Upgraded to v%s",
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
step2: "Install AI skills",
step2Skip: "Already installed. Skipped",
step2Spinner: "Installing skills...",
step2Done: "Skills installed",
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
step3: "Configuring app...",
step3NotFound: "lark-cli not found. Aborting",
step3Found: "Found existing app (App ID: %s). Use this app?",
step3Skip: "Skipped app configuration",
step3Done: "App configured",
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
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",
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
cancelled: "Installation cancelled",
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function handleCancel(value, msg) {
if (p.isCancel(value)) {
p.cancel(msg.cancelled);
process.exit(0);
}
return value;
}
function execCmd(cmd, args, opts) {
if (isWindows) {
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
}
return execFileSync(cmd, args, opts);
}
function run(cmd, args, opts = {}) {
execCmd(cmd, args, { stdio: "inherit", ...opts });
}
function runSilent(cmd, args, opts = {}) {
return execCmd(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
});
}
function runSilentAsync(cmd, args, opts = {}) {
const actualCmd = isWindows ? "cmd.exe" : cmd;
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
return new Promise((resolve, reject) => {
execFile(actualCmd, actualArgs, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
}, (err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
});
}
function fmt(template, ...values) {
let i = 0;
return template.replace(/%s/g, () => values[i++] ?? "");
}
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
function whichLarkCli() {
try {
const prefix = execFileSync("npm", ["prefix", "-g"], {
stdio: ["ignore", "pipe", "pipe"],
}).toString().trim();
const bin = isWindows
? path.join(prefix, "lark-cli.cmd")
: path.join(prefix, "bin", "lark-cli");
if (fs.existsSync(bin)) return bin;
} catch (_) {
// fall through
}
// Fallback to which/where if npm prefix lookup fails.
try {
const cmd = isWindows ? "where" : "which";
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
.toString()
.split("\n")[0]
.trim();
} catch (_) {
return null;
}
}
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
function getLatestVersion() {
try {
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
const ver = out.toString().trim();
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
} catch (_) {
return null;
}
}
/** Compare two semver strings. Returns true if a < b. */
function semverLessThan(a, b) {
const pa = a.replace(/-.*$/, "").split(".").map(Number);
const pb = b.replace(/-.*$/, "").split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) < (pb[i] || 0)) return true;
if ((pa[i] || 0) > (pb[i] || 0)) return false;
}
return false;
}
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
function getGloballyInstalledVersion() {
try {
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
return match ? match[1] : "unknown";
} catch (_) {
return null;
}
}
/** Check whether lark-cli config already exists. Returns app ID or null. */
function getExistingAppId(binPath) {
try {
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
const json = JSON.parse(out.toString());
return json.appId || null;
} catch (_) {
return null;
}
}
/** Parse --lang from process.argv, returns "zh", "en", or null. */
function parseLangArg() {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === "--lang" && args[i + 1]) {
const val = args[i + 1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
if (args[i].startsWith("--lang=")) {
const val = args[i].split("=")[1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
async function stepSelectLang() {
const fromArg = parseLangArg();
if (fromArg) return fromArg;
const lang = await p.select({
message: "请选择语言 / Select language",
options: [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
],
});
return handleCancel(lang, messages.zh);
}
async function stepInstallGlobally(msg) {
const installedVer = getGloballyInstalledVersion();
const latestVer = getLatestVersion();
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
if (installedVer && !needsUpgrade) {
p.log.info(fmt(msg.step1Skip, installedVer));
return false;
}
const s = p.spinner();
if (needsUpgrade) {
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
} else {
s.start(fmt(msg.step1, PKG));
}
try {
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
return needsUpgrade;
} catch (_) {
s.stop(fmt(msg.step1Fail, PKG));
process.exit(1);
}
}
async function skillsAlreadyInstalled() {
try {
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
timeout: 120000,
});
return /^lark-/m.test(out.toString());
} catch (_) {
return false;
}
}
async function stepInstallSkills(msg) {
const s = p.spinner();
s.start(msg.step2Spinner);
try {
if (await skillsAlreadyInstalled()) {
s.stop(msg.step2Skip);
return;
}
try {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
timeout: 120000,
});
} catch (_) {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
timeout: 120000,
});
}
s.stop(msg.step2Done);
} catch (_) {
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
process.exit(1);
}
}
async function stepConfigInit(msg, lang) {
const s = p.spinner();
s.start(msg.step3);
const larkCli = whichLarkCli();
if (!larkCli) {
s.stop(msg.step3NotFound);
process.exit(1);
}
const appId = getExistingAppId(larkCli);
s.stop(msg.step3);
if (appId) {
const reuse = await p.confirm({
message: fmt(msg.step3Found, appId),
});
if (handleCancel(reuse, msg) && reuse) {
p.log.info(msg.step3Skip);
return;
}
}
try {
run(larkCli, ["config", "init", "--new", "--lang", lang]);
p.log.success(msg.step3Done);
} catch (_) {
p.log.error(msg.step3Fail);
process.exit(1);
}
}
async function stepAuthLogin(msg) {
const larkCli = whichLarkCli();
if (!larkCli) {
p.log.warn(msg.step4NotFound);
return;
}
const yes = await p.confirm({
message: msg.step4Confirm,
});
if (p.isCancel(yes)) {
p.cancel(msg.cancelled);
process.exit(0);
}
if (!yes) {
p.log.info(msg.step4Skip);
return;
}
p.log.step(msg.step4);
try {
run(larkCli, ["auth", "login"]);
p.log.success(msg.step4Done);
} catch (_) {
p.log.warn(msg.step4Fail);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const lang = await stepSelectLang();
const msg = messages[lang];
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
}
main().catch((err) => {
p.cancel("Unexpected error: " + (err.message || err));
process.exit(1);
});

View File

@@ -3,10 +3,10 @@
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const { execFileSync } = require("child_process");
const os = require("os");
const VERSION = require("../package.json").version;
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const REPO = "larksuite/cli";
const NAME = "lark-cli";
@@ -43,13 +43,16 @@ const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
fs.mkdirSync(binDir, { recursive: true });
function download(url, destPath) {
const args = [
"--fail", "--location", "--silent", "--show-error",
"--connect-timeout", "10", "--max-time", "120",
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
execSync(
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
{ stdio: ["ignore", "ignore", "pipe"] }
);
if (isWindows) args.unshift("--ssl-revoke-best-effort");
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function install() {
@@ -64,12 +67,12 @@ function install() {
}
if (isWindows) {
execSync(
`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`,
{ stdio: "ignore" }
);
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
} else {
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
});
}
@@ -85,6 +88,16 @@ function install() {
}
}
// When triggered as a postinstall hook under npx, skip the binary download.
// The "install" wizard doesn't need it, and run.js calls install.js directly
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
const isNpxPostinstall =
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
if (isNpxPostinstall) {
process.exit(0);
}
try {
install();
} catch (err) {

View File

@@ -41,21 +41,32 @@ if (process.platform === "win32" && fs.existsSync(oldBin)) {
}
}
if (!fs.existsSync(bin)) {
console.error(
`Error: lark-cli binary not found at ${bin}\n\n` +
`This usually means the postinstall script was skipped.\n` +
`Common causes:\n` +
` - npm is configured with ignore-scripts=true\n` +
` - The postinstall download failed\n\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
// Intercept "install" subcommand — run the setup wizard directly,
// bypassing the native binary (which may not exist yet under npx).
const args = process.argv.slice(2);
if (args[0] === "install") {
require("./install-wizard.js");
} else {
// Auto-download binary if missing (e.g. npx skipped postinstall).
if (!fs.existsSync(bin)) {
try {
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
stdio: "inherit",
env: { ...process.env, LARK_CLI_RUN: "true" },
});
} catch (_) {
console.error(
`\nFailed to auto-install lark-cli binary.\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
}
try {
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
try {
execFileSync(bin, args, { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
}
}

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) {
@@ -151,6 +429,87 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
}
}
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "field create",
shortcut: BaseFieldCreate,
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "field update",
shortcut: BaseFieldUpdate,
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
},
{
name: "record search",
shortcut: BaseRecordSearch,
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record upsert",
shortcut: BaseRecordUpsert,
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), "lark-base skill") {
t.Fatalf("err=%v", err)
}
if strings.Contains(err.Error(), "array") {
t.Fatalf("err should not mention array: %v", err)
}
if got := stdout.String(); got != "" {
t.Fatalf("stdout=%q, want empty", got)
}
})
}
}
func TestBaseTableExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -259,7 +618,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
},
})
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
@@ -277,7 +636,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
},
})
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
@@ -860,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)
}
})
@@ -1011,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)
}
@@ -1203,7 +1566,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
factory,
stdout,
)
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
})

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

@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
}
func jsonInputTip(flagName string) string {
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
}
func formatJSONError(flagName string, target string, err error) error {

View File

@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
}
}
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate != nil {
t.Fatalf("expected no validate hook, got non-nil")
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate == nil {
t.Fatal("expected validate hook")
}
}
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
@@ -255,22 +255,29 @@ func TestBaseRecordValidate(t *testing.T) {
if BaseRecordList.Validate != nil {
t.Fatalf("record list validate should be nil for repeatable --field-id")
}
if BaseRecordSearch.Validate != nil {
t.Fatalf("record search validate should be nil for API passthrough")
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
}
if BaseRecordUpsert.Validate != nil {
t.Fatalf("record upsert validate should be nil for API passthrough")
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
}
}
func TestBaseViewValidate(t *testing.T) {
ctx := context.Background()
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
}

View File

@@ -81,16 +81,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
if raw == "" {
return nil, nil
}
var body map[string]interface{}
_ = common.ParseJSON([]byte(raw), &body)
if body == nil {
return nil, nil
}
return body, nil
return parseJSONObject(pc, runtime.Str("json"), "json")
}
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {

View File

@@ -6,6 +6,7 @@ package base
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -36,7 +37,14 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
}
var result map[string]interface{}
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
return nil, formatJSONError(flagName, "object", err)
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err)
}
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
if result == nil {
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
return result, nil
}

View File

@@ -38,7 +38,10 @@ func TestParseHelpers(t *testing.T) {
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
@@ -63,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -281,11 +284,11 @@ func TestJSONInputHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
t.Fatalf("syntaxErr=%v", syntaxErr)
}
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
t.Fatalf("typeErr=%v", typeErr)
}
}

View File

@@ -25,6 +25,9 @@ var BaseRecordBatchCreate = common.Shortcut{
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordBatchCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordBatchCreate(runtime)

View File

@@ -25,6 +25,9 @@ var BaseRecordBatchUpdate = common.Shortcut{
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordBatchUpdate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordBatchUpdate(runtime)

View File

@@ -113,7 +113,9 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
}
func validateRecordJSON(runtime *common.RuntimeContext) error {
return nil
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
}
func recordListFields(runtime *common.RuntimeContext) []string {

View File

@@ -25,6 +25,9 @@ var BaseRecordSearch = common.Shortcut{
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordSearch,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordSearch(runtime)

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

@@ -26,6 +26,9 @@ var BaseRecordUpsert = common.Shortcut{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordUpsert,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordUpsert(runtime)

View File

@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
}
func validateViewCreate(runtime *common.RuntimeContext) error {
return nil
pc := newParseCtx(runtime)
_, err := parseObjectList(pc, runtime.Str("json"), "json")
return err
}
func validateViewJSONObject(runtime *common.RuntimeContext) error {
return nil
}
func validateViewJSONValue(runtime *common.RuntimeContext) error {
return nil
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
}
func executeViewList(runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetGroup,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetSort,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -26,6 +26,9 @@ var BaseViewSetVisibleFields = common.Shortcut{
`Example: --json '{"visible_fields":["fldXXX"]}'`,
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetVisibleFields,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeViewSetVisibleFields(runtime)

View File

@@ -263,8 +263,8 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
// that must be called with TAT even when the surrounding shortcut runs as user.
// regardless of the current --as flag. Use this for APIs that must always be called
// with TAT even when the surrounding shortcut runs as user.
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ac, err := ctx.getAPIClient()
if err != nil {

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

@@ -28,6 +28,8 @@ var DocsFetch = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
@@ -46,6 +48,8 @@ var DocsFetch = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)

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

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveCreateFolderSpec struct {
Name string
FolderToken string
}
func newDriveCreateFolderSpec(runtime *common.RuntimeContext) driveCreateFolderSpec {
return driveCreateFolderSpec{
Name: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
func (s driveCreateFolderSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"name": s.Name,
"folder_token": s.FolderToken,
}
}
// DriveCreateFolder creates a new Drive folder under the specified parent
// folder, or under the caller's root folder when --folder-token is omitted.
var DriveCreateFolder = common.Shortcut{
Service: "drive",
Command: "+create-folder",
Description: "Create a folder in Drive",
Risk: "write",
Scopes: []string{"space:folder:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "name", Desc: "folder name", Required: true},
{Name: "folder-token", Desc: "parent folder token (default: root folder)"},
},
Tips: []string{
"Omit --folder-token to create the folder in the caller's root folder.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveCreateFolderSpec(newDriveCreateFolderSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveCreateFolderSpec(runtime)
dry := common.NewDryRunAPI().
Desc("Create a folder in Drive").
POST("/open-apis/drive/v1/files/create_folder").
Desc("[1] Create folder").
Body(spec.RequestBody())
if runtime.IsBot() {
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveCreateFolderSpec(runtime)
target := "root folder"
if spec.FolderToken != "" {
target = "folder " + common.MaskToken(spec.FolderToken)
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target)
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
folderToken := common.GetString(data, "token")
if folderToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)")
}
out := map[string]interface{}{
"created": true,
"name": spec.Name,
"folder_token": folderToken,
"parent_folder_token": spec.FolderToken,
}
if url := common.GetString(data, "url"); url != "" {
out["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
out["permission_grant"] = grant
}
runtime.Out(out, nil)
return nil
},
}
func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error {
if spec.Name == "" {
return output.ErrValidation("--name must not be empty")
}
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}

View File

@@ -0,0 +1,266 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"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"
"github.com/larksuite/cli/shortcuts/common"
)
func TestValidateDriveCreateFolderSpecRejectsInvalidInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveCreateFolderSpec
wantErr string
}{
{
name: "empty name",
spec: driveCreateFolderSpec{},
wantErr: "--name must not be empty",
},
{
name: "name too long",
spec: driveCreateFolderSpec{
Name: strings.Repeat("a", 257),
},
wantErr: "maximum of 256 bytes",
},
{
name: "invalid folder token",
spec: driveCreateFolderSpec{
Name: "Reports",
FolderToken: "../bad",
},
wantErr: "--folder-token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCreateFolderSpec(tt.spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-folder"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("name", " Weekly Reports "); err != nil {
t.Fatalf("set --name: %v", err)
}
if err := cmd.Flags().Set("folder-token", " fld_parent "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
dry := DriveCreateFolder.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/drive/v1/files/create_folder" {
t.Fatalf("unexpected dry-run API call: %#v", got.API[0])
}
if got.API[0].Body["name"] != "Weekly Reports" {
t.Fatalf("name = %#v, want %q", got.API[0].Body["name"], "Weekly Reports")
}
if got.API[0].Body["folder_token"] != "fld_parent" {
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
}
}
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"token": "fld_created",
"url": "https://example.feishu.cn/drive/folder/fld_created",
},
},
}
reg.Register(createStub)
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/fld_created/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
reg.Register(permStub)
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", " Weekly Reports ",
"--folder-token", " fld_parent ",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["name"] != "Weekly Reports" {
t.Fatalf("name = %#v, want %q", body["name"], "Weekly Reports")
}
if body["folder_token"] != "fld_parent" {
t.Fatalf("folder_token = %#v, want %q", body["folder_token"], "fld_parent")
}
data := decodeDriveEnvelope(t, stdout)
if data["folder_token"] != "fld_created" {
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_created")
}
if data["parent_folder_token"] != "fld_parent" {
t.Fatalf("parent_folder_token = %#v, want %q", data["parent_folder_token"], "fld_parent")
}
if data["name"] != "Weekly Reports" {
t.Fatalf("name = %#v, want %q", data["name"], "Weekly Reports")
}
if data["url"] != "https://example.feishu.cn/drive/folder/fld_created" {
t.Fatalf("url = %#v, want folder url", data["url"])
}
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_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
permBody := decodeCapturedJSONBody(t, permStub)
if permBody["member_type"] != "openid" || permBody["member_id"] != "ou_current_user" || permBody["perm"] != "full_access" || permBody["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", permBody)
}
}
func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"token": "fld_root_child",
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", "Inbox",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["folder_token"] != "" {
t.Fatalf("folder_token = %#v, want empty string for root create", body["folder_token"])
}
data := decodeDriveEnvelope(t, stdout)
if data["folder_token"] != "fld_root_child" {
t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_root_child")
}
if data["parent_folder_token"] != "" {
t.Fatalf("parent_folder_token = %#v, want empty string", data["parent_folder_token"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"url": "https://example.feishu.cn/drive/folder/unknown",
},
},
})
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", "Broken Folder",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "returned no folder token") {
t.Fatalf("err = %v, want missing folder token error", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on error, got %s", stdout.String())
}
}

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateFolder,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,

View File

@@ -12,6 +12,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
got := Shortcuts()
want := []string{
"+upload",
"+create-folder",
"+create-shortcut",
"+download",
"+add-comment",

View File

@@ -74,6 +74,7 @@ var commonEventTypes = []string{
"approval.approval.updated",
"application.application.visibility.added_v6",
"task.task.update_tenant_v1",
"task.task.update_user_access_v2",
"task.task.comment_updated_v1",
"drive.notice.comment_add_v1",
}

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

@@ -602,3 +602,51 @@ func TestMediaBufferReader(t *testing.T) {
}
}
}
func TestMediaBufferFileName(t *testing.T) {
tests := []struct {
label string
buf mediaBuffer
want string
}{
{"original URL filename", mediaBuffer{name: "report.pdf", ext: ".pdf"}, "report.pdf"},
{"name with spaces", mediaBuffer{name: "Q1 report.pdf", ext: ".pdf"}, "Q1 report.pdf"},
{"download fallback", mediaBuffer{name: "download", ext: ""}, "download"},
{"ext not leaked into name", mediaBuffer{name: "x", ext: ".mp4"}, "x"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
if got := tt.buf.FileName(); got != tt.want {
t.Fatalf("FileName() = %q, want %q", got, tt.want)
}
})
}
}
// TestNewMediaBufferFromBytesURLFilename locks in the URL -> mediaBuffer.name
// wiring so a future refactor cannot regress back to the "media.<ext>" synthetic
// filename that was shipped in 91067ec.
func TestNewMediaBufferFromBytesURLFilename(t *testing.T) {
tests := []struct {
label string
url string
want string
}{
{"path filename", "http://example.com/report.pdf", "report.pdf"},
{"filename survives query string", "http://example.com/videos/clip.mp4?token=abc", "clip.mp4"},
{"percent-encoded spaces decoded", "http://example.com/Q1%20report.pdf", "Q1 report.pdf"},
{"no path falls back to download", "http://example.com/", "download"},
{"non-http scheme falls back to download", "ftp://example.com/x.pdf", "download"},
}
for _, tt := range tests {
t.Run(tt.label, func(t *testing.T) {
mb := newMediaBufferFromBytes([]byte("payload"), ".pdf", tt.url)
if got := mb.FileName(); got != tt.want {
t.Fatalf("FileName() for %q = %q, want %q", tt.url, got, tt.want)
}
if got := mb.FileName(); strings.HasPrefix(got, "media") && tt.want != "media" {
t.Fatalf("regression: FileName() returned synthetic %q for %q", got, tt.url)
}
})
}
}

View File

@@ -584,6 +584,7 @@ func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType strin
type mediaBuffer struct {
data []byte
ext string // file extension including leading dot, e.g. ".mp4"
name string // original file name extracted from the source URL
}
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
@@ -598,7 +599,14 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
return &mediaBuffer{data: data, ext: ext}, nil
return newMediaBufferFromBytes(data, ext, rawURL), nil
}
// newMediaBufferFromBytes builds a mediaBuffer from already-downloaded bytes.
// Split out from newMediaBuffer so the URL-to-filename wiring is testable
// without going through the hardened download transport.
func newMediaBufferFromBytes(data []byte, ext, rawURL string) *mediaBuffer {
return &mediaBuffer{data: data, ext: ext, name: fileNameFromURL(rawURL)}
}
// Reader returns a new io.Reader over the buffered data. Each call returns a
@@ -608,9 +616,9 @@ func (b *mediaBuffer) Reader() io.Reader {
return bytes.NewReader(b.data)
}
// FileName returns a synthetic file name based on the URL extension.
// FileName returns the original file name extracted from the source URL.
func (b *mediaBuffer) FileName() string {
return "media" + b.ext
return b.name
}
// FileType returns the IM file type detected from the extension.
@@ -1131,7 +1139,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
fd.AddField("image_type", imageType)
fd.AddFile("image", f)
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -1172,7 +1180,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,
@@ -1200,7 +1208,7 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
fd.AddField("image_type", imageType)
fd.AddFile("image", r)
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -1232,7 +1240,7 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
}
fd.AddFile("file", r)
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,

View File

@@ -892,3 +892,38 @@ func TestResolveLocalMediaFile(t *testing.T) {
t.Fatalf("resolveLocalMedia(file) = %q, want %q", got, "file_via_resolve")
}
}
// TestUploadFileToIMPreservesLocalFileName locks in that local uploads keep
// the basename of the caller-supplied path as the multipart file_name, so the
// URL-side fix for mediaBuffer cannot silently regress the local branch later.
func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
var gotBody string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/files") {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
gotBody = string(body)
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_key": "file_uploaded"},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
cmdutil.TestChdir(t, t.TempDir())
localName := "Q1-meeting-notes.pdf"
if err := os.WriteFile(localName, []byte("pdfdata"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
t.Fatalf("uploadFileToIM() error = %v", err)
}
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
t.Fatalf("upload body missing local filename %q; got: %q", localName, gotBody)
}
}

View File

@@ -144,6 +144,8 @@ type DraftProjection struct {
BodyText string `json:"body_text,omitempty"`
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
HasSignature bool `json:"has_signature,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
Warnings []string `json:"warnings,omitempty"`
@@ -182,6 +184,22 @@ type PatchOp struct {
FileName string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"`
Target AttachmentTarget `json:"target,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
// fetching and interpolating the signature. The patch layer uses this
// pre-rendered content for insert_signature ops.
RenderedSignatureHTML string `json:"-"`
SignatureImages []SignatureImage `json:"-"`
}
// SignatureImage holds pre-downloaded image data for signature inline images.
// Populated by the shortcut layer, consumed by the patch layer.
type SignatureImage struct {
CID string
ContentType string
FileName string
Data []byte
}
func (p Patch) Validate() error {
@@ -274,6 +292,12 @@ func (op PatchOp) Validate() error {
if !op.Target.hasKey() {
return fmt.Errorf("remove_inline requires target with at least one of part_id or cid")
}
case "insert_signature":
if strings.TrimSpace(op.SignatureID) == "" {
return fmt.Errorf("insert_signature requires signature_id")
}
case "remove_signature":
// No required fields.
default:
return fmt.Errorf("unsupported op %q", op.Op)
}

View File

@@ -33,10 +33,12 @@ var protectedHeaders = map[string]bool{
// bodyChangingOps lists patch operations that modify the HTML body content,
// which is the trigger for running local image path resolution.
var bodyChangingOps = map[string]bool{
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
"insert_signature": true,
"remove_signature": true,
}
func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error {
@@ -121,6 +123,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return fmt.Errorf("remove_inline: %w", err)
}
return removeInline(snapshot, partID)
case "insert_signature":
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}
@@ -284,7 +290,7 @@ func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) e
if htmlPart == nil {
return setBody(snapshot, value, options)
}
_, quotePart := splitAtQuote(string(htmlPart.Body))
_, quotePart := SplitAtQuote(string(htmlPart.Body))
if quotePart == "" {
// No quote block found — fall back to regular set_body.
return setBody(snapshot, value, options)
@@ -1135,3 +1141,166 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc
removeOrphanedInlineParts(snapshot.Body, refSet)
return nil
}
// ── Signature patch operations ──
// insertSignatureOp inserts a pre-rendered signature into the HTML body.
// The RenderedSignatureHTML and SignatureImages fields must be populated
// by the shortcut layer before calling Apply.
func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("insert_signature: no HTML body part found; use set_body first")
}
html := string(htmlPart.Body)
// Collect CIDs from old signature before removing it, so we can
// clean up orphaned MIME inline parts and avoid duplicates.
oldSigCIDs := collectSignatureCIDsFromHTML(html)
// Remove existing signature (if any), including preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned MIME inline parts from old signature.
for _, cid := range oldSigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
// Split at quote and insert signature between body and quote.
body, quote := SplitAtQuote(html)
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
html = body + sigBlock + quote
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
// Add signature inline images to the MIME tree.
for _, img := range op.SignatureImages {
addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID)
}
syncTextPartFromHTML(snapshot, html)
return nil
}
// removeSignatureOp removes the signature block from the HTML body.
func removeSignatureOp(snapshot *DraftSnapshot) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("remove_signature: no HTML body part found")
}
html := string(htmlPart.Body)
if !signatureWrapperRe.MatchString(html) {
return fmt.Errorf("no signature found in draft body")
}
// Collect CIDs referenced by the signature before removing it.
sigCIDs := collectSignatureCIDsFromHTML(html)
// Remove signature and preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
for _, cid := range sigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
syncTextPartFromHTML(snapshot, html)
return nil
}
// syncTextPartFromHTML regenerates the text/plain part from the current HTML,
// mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
func syncTextPartFromHTML(snapshot *DraftSnapshot, html string) {
if snapshot.PrimaryTextPartID == "" {
return
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil {
return
}
textPart.Body = []byte(plainTextFromHTML(html))
textPart.Dirty = true
}
// Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
// RemoveSignatureHTML are exported from projection.go to avoid duplication
// with the mail package's signature_html.go.
// collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
func collectSignatureCIDsFromHTML(html string) []string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return nil
}
sigEnd := FindMatchingCloseDiv(html, loc[0])
sigHTML := html[loc[0]:sigEnd]
matches := cidRefRegexp.FindAllStringSubmatch(sigHTML, -1)
cids := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) >= 2 {
cids = append(cids, m[1])
}
}
return cids
}
// removeMIMEPartByCID removes the first MIME part with the given Content-ID.
func removeMIMEPartByCID(root *Part, cid string) {
if root == nil {
return
}
normalizedCID := strings.Trim(cid, "<>")
for i, child := range root.Children {
if child == nil {
continue
}
childCID := strings.Trim(child.ContentID, "<>")
if strings.EqualFold(childCID, normalizedCID) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
return
}
removeMIMEPartByCID(child, cid)
}
}
// addInlinePartToSnapshot adds an inline image part to the MIME tree.
func addInlinePartToSnapshot(snapshot *DraftSnapshot, data []byte, contentType, filename, cid string) {
part := &Part{
MediaType: contentType,
ContentDisposition: "inline",
ContentID: strings.Trim(cid, "<>"),
Body: data,
Dirty: true,
}
if filename != "" {
part.MediaParams = map[string]string{"name": filename}
}
// Find or create the multipart/related container.
if snapshot.Body == nil {
return
}
if snapshot.Body.IsMultipart() {
snapshot.Body.Children = append(snapshot.Body.Children, part)
}
// Non-multipart body: inline part is not added. This is expected when
// the draft has a simple text/html body without multipart/related wrapper.
// The signature HTML still references the CID, but the image won't render.
// In practice, compose shortcuts wrap the body in multipart/related when
// inline images are present, so this path rarely triggers.
}
// containsCIDIgnoreCase checks if html contains a "cid:<value>" reference,
// case-insensitively. Aligned with other CID comparisons in this package.
func containsCIDIgnoreCase(html, cid string) bool {
return strings.Contains(strings.ToLower(html), "cid:"+strings.ToLower(cid))
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// insert_signature — basic insertion into HTML body
// ---------------------------------------------------------------------------
func TestInsertSignature_BasicHTML(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Sig test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-123",
RenderedSignatureHTML: "<div>-- My Signature</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if !strings.Contains(html, "My Signature") {
t.Error("signature not found in HTML body")
}
if !strings.Contains(html, `class="lark-mail-signature"`) {
t.Error("signature wrapper class not found")
}
if !strings.Contains(html, `id="sig-123"`) {
t.Error("signature ID not found")
}
// Body text should come before signature
bodyIdx := strings.Index(html, "Hello")
sigIdx := strings.Index(html, "My Signature")
if bodyIdx > sigIdx {
t.Error("signature should appear after body text")
}
}
// ---------------------------------------------------------------------------
// insert_signature — with quoted content (reply/forward)
// ---------------------------------------------------------------------------
func TestInsertSignature_BeforeQuote(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Reply with sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>My reply</p><div id="lark-mail-quote-cli123" class="history-quote-wrapper"><div>quoted content</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-456",
RenderedSignatureHTML: "<div>-- Reply Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
sigIdx := strings.Index(html, "Reply Sig")
quoteIdx := strings.Index(html, "quoted content")
if sigIdx < 0 || quoteIdx < 0 {
t.Fatalf("missing signature or quote in: %s", html)
}
if sigIdx > quoteIdx {
t.Error("signature should appear before quote block")
}
}
// ---------------------------------------------------------------------------
// insert_signature — replaces existing signature
// ---------------------------------------------------------------------------
func TestInsertSignature_ReplacesExisting(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Replace sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p><div id="old-sig" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- Old Sig</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "new-sig",
RenderedSignatureHTML: "<div>-- New Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "Old Sig") {
t.Error("old signature should have been removed")
}
if !strings.Contains(html, "New Sig") {
t.Error("new signature not found")
}
}
// ---------------------------------------------------------------------------
// insert_signature — no HTML body
// ---------------------------------------------------------------------------
func TestInsertSignature_NoHTMLBody(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain text
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Just plain text`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-x",
RenderedSignatureHTML: "<div>sig</div>",
}},
})
if err == nil {
t.Fatal("expected error for insert_signature on plain text draft")
}
if !strings.Contains(err.Error(), "no HTML body") {
t.Fatalf("expected 'no HTML body' error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// remove_signature — removes existing signature
// ---------------------------------------------------------------------------
func TestRemoveSignature_Basic(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Remove sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p><div id="sig-rm" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- My Sig</div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_signature"}},
})
if err != nil {
t.Fatalf("Apply remove_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "My Sig") {
t.Error("signature should have been removed")
}
if strings.Contains(html, "lark-mail-signature") {
t.Error("signature wrapper should have been removed")
}
if !strings.Contains(html, "Hello") {
t.Error("body text should be preserved")
}
}
// ---------------------------------------------------------------------------
// remove_signature — no signature present
// ---------------------------------------------------------------------------
func TestRemoveSignature_NoSignature(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: No sig
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>No signature here</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_signature"}},
})
if err == nil {
t.Fatal("expected error when removing non-existent signature")
}
if !strings.Contains(err.Error(), "no signature found") {
t.Fatalf("expected 'no signature found' error, got: %v", err)
}
}

View File

@@ -4,6 +4,7 @@
package draft
import (
"html"
"regexp"
"strings"
)
@@ -27,6 +28,18 @@ var quoteWrapperRe = regexp.MustCompile(`<div\s[^>]*class="[^"]*` + QuoteWrapper
var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`)
// SignatureWrapperClass is the CSS class for the mail signature container.
const SignatureWrapperClass = "lark-mail-signature"
var signatureWrapperRe = regexp.MustCompile(
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`)
// signatureIDRe extracts the id from a signature wrapper div, regardless of
// whether id appears before or after the class attribute.
var signatureIDRe = regexp.MustCompile(
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` +
`|<div\s[^>]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass)
func Project(snapshot *DraftSnapshot) DraftProjection {
proj := DraftProjection{
Subject: snapshot.Subject,
@@ -45,6 +58,17 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
html := string(part.Body)
proj.BodyHTMLSummary = summarizeHTML(html)
proj.HasQuotedContent = hasQuotedContent(html)
proj.HasSignature = signatureWrapperRe.MatchString(html)
if proj.HasSignature {
if m := signatureIDRe.FindStringSubmatch(html); m != nil {
// alternation regex: id is in m[1] (class-first) or m[2] (id-first)
if m[1] != "" {
proj.SignatureID = m[1]
} else if len(m) >= 3 {
proj.SignatureID = m[2]
}
}
}
}
parts := flattenParts(snapshot.Body)
@@ -128,10 +152,10 @@ func hasQuotedContent(html string) bool {
return quoteWrapperRe.MatchString(html)
}
// splitAtQuote splits an HTML body into the user-authored content and
// SplitAtQuote splits an HTML body into the user-authored content and
// the trailing reply/forward quote block. If no quote block is found,
// quote is empty and body is the original html unchanged.
func splitAtQuote(html string) (body, quote string) {
func SplitAtQuote(html string) (body, quote string) {
loc := quoteWrapperRe.FindStringIndex(html)
if loc == nil {
return html, ""
@@ -139,6 +163,70 @@ func splitAtQuote(html string) (body, quote string) {
return html[:loc[0]], html[loc[0]:]
}
// ── Exported signature HTML utilities ──
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
// signatureSpacingRe matches 1-2 empty-line divs before the signature.
var signatureSpacingRe = regexp.MustCompile(
`(?:<div[^>]*><div[^>]*><br></div></div>\s*){1,2}$`)
// SignatureSpacingRe returns the compiled regex for signature spacing detection.
func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe }
// SignatureSpacing returns the 2 empty-line divs placed before the signature,
// matching the structure generated by the Lark mail editor.
func SignatureSpacing() string {
line := `<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto"><br></div></div>`
return line + line
}
// BuildSignatureHTML wraps signature content in the standard signature container div.
// sigID is HTML-escaped to prevent attribute injection.
func BuildSignatureHTML(sigID, content string) string {
return `<div id="` + html.EscapeString(sigID) + `" class="` + SignatureWrapperClass + `" style="padding-top:6px;padding-bottom:6px">` + content + `</div>`
}
// FindMatchingCloseDiv finds the position after the closing </div> that matches
// the <div at startPos, tracking nesting depth.
func FindMatchingCloseDiv(html string, startPos int) int {
depth := 0
i := startPos
for i < len(html) {
if strings.HasPrefix(html[i:], "<div") {
depth++
i += 4
} else if strings.HasPrefix(html[i:], "</div>") {
depth--
i += 6
if depth == 0 {
return i
}
} else {
i++
}
}
return len(html)
}
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
// Returns the HTML unchanged if no signature is found.
func RemoveSignatureHTML(html string) string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return html
}
sigStart := loc[0]
sigEnd := FindMatchingCloseDiv(html, sigStart)
// Extend backward to include preceding spacing.
beforeSig := html[:sigStart]
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
sigStart = spacingLoc[0]
}
return html[:sigStart] + html[sigEnd:]
}
func summarizeHTML(html string) string {
trimmed := strings.TrimSpace(html)
runes := []rune(trimmed)

View File

@@ -100,7 +100,7 @@ Content-Type: text/html; charset=UTF-8
func TestSplitAtQuoteReply(t *testing.T) {
html := `<div>My reply</div><div class="history-quote-wrapper"><div>quoted</div></div>`
body, quote := splitAtQuote(html)
body, quote := SplitAtQuote(html)
if body != `<div>My reply</div>` {
t.Fatalf("body = %q", body)
}
@@ -111,7 +111,7 @@ func TestSplitAtQuoteReply(t *testing.T) {
func TestSplitAtQuoteForward(t *testing.T) {
html := `<div>note</div><div id="lark-mail-quote-cli123456" class="history-quote-wrapper"><div>quoted</div></div>`
body, quote := splitAtQuote(html)
body, quote := SplitAtQuote(html)
if body != `<div>note</div>` {
t.Fatalf("body = %q", body)
}
@@ -122,7 +122,7 @@ func TestSplitAtQuoteForward(t *testing.T) {
func TestSplitAtQuoteNoQuote(t *testing.T) {
html := `<div>no quote here</div>`
body, quote := splitAtQuote(html)
body, quote := SplitAtQuote(html)
if body != html {
t.Fatalf("body = %q, want original html", body)
}
@@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
html := `<p>The CSS class history-quote-wrapper is used for quotes.</p>`
body, quote := splitAtQuote(html)
body, quote := SplitAtQuote(html)
if body != html {
t.Fatalf("body should be unchanged, got %q", body)
}

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
@@ -1933,6 +2009,23 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
return nil
}
// buildSendResult builds the output map for a successful send, including
// recall tip if the backend indicates the message is recallable.
func buildSendResult(resData map[string]interface{}, mailboxID string) map[string]interface{} {
result := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
messageID, _ := resData["message_id"].(string)
result["recall_available"] = true
result["recall_tip"] = fmt.Sprintf(
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
mailboxID, messageID)
}
return result
}
// validateFolderReadScope checks that the user's token includes the
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
// before hitting the folders API. System folders are resolved locally and

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

@@ -46,6 +46,8 @@ var MailDraftCreate = common.Shortcut{
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
{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)
@@ -72,21 +74,32 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the full email body")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
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
}
rawEML, err := buildRawEMLForDraftCreate(runtime, input)
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, priority)
if err != nil {
return err
}
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
@@ -121,7 +134,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
return input, nil
}
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (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")
@@ -153,12 +166,18 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
var autoResolvedPaths []string
if input.PlainText {
bld = bld.TextBody([]byte(input.Body))
} else if bodyIsHTML(input.Body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body
if !bodyIsHTML(input.Body) {
htmlBody = buildBodyDiv(input.Body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return "", resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -169,12 +188,14 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
}
} 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)
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)
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)
_, 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)
_, 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)
_, 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)
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 {
@@ -92,6 +93,24 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
// Pre-process insert_signature ops: resolve signature using the draft's
// From address so alias/shared-mailbox senders get correct template vars.
var draftFromEmail string
if len(snapshot.From) > 0 {
draftFromEmail = snapshot.From[0].Address
}
for i := range patch.Ops {
if patch.Ops[i].Op == "insert_signature" {
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
if sigErr != nil {
return sigErr
}
if sigResult != nil {
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
patch.Ops[i].SignatureImages = sigResult.Images
}
}
}
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
return output.ErrValidation("apply draft patch failed: %v", err)
@@ -258,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)")
}
@@ -313,6 +345,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"},
},
"supported_ops_by_group": []map[string]interface{}{
{
@@ -348,6 +382,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
},
{
"group": "signature",
"ops": []map[string]interface{}{
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"},
},
},
},
"recommended_usage": []string{
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",

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."},
},
{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,12 +61,21 @@ 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
}
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
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")
@@ -76,8 +87,19 @@ 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)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -114,7 +136,7 @@ var MailForward = common.Shortcut{
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -138,8 +160,13 @@ var MailForward = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + forwardQuote
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + forwardQuote
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -150,12 +177,13 @@ var MailForward = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
return err
}
} 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
@@ -218,14 +246,11 @@ 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)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

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."},
},
{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,7 +58,16 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
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 := 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")
@@ -68,13 +79,24 @@ 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 {
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -92,7 +114,7 @@ var MailReply = common.Shortcut{
}
replyTo = mergeAddrLists(replyTo, toFlag)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -139,8 +161,13 @@ var MailReply = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + quoted
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -151,12 +178,13 @@ var MailReply = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
return err
}
} 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
@@ -181,14 +209,11 @@ 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)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

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."},
},
{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,7 +59,16 @@ var MailReplyAll = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
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 := 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")
@@ -70,13 +81,24 @@ 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 {
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -110,7 +132,7 @@ var MailReplyAll = common.Shortcut{
return err
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -153,8 +175,13 @@ var MailReplyAll = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + quoted
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -165,12 +192,13 @@ var MailReplyAll = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
return err
}
} 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
@@ -195,14 +223,11 @@ 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)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

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."},
},
{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,6 +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 {
@@ -74,8 +85,20 @@ 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)
if err != nil {
return err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
Subject(subject).
@@ -96,12 +119,19 @@ var MailSend = common.Shortcut{
var autoResolvedPaths []string
if plainText {
bld = bld.TextBody([]byte(body))
} else if bodyIsHTML(body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.
htmlBody := body
if !bodyIsHTML(body) {
htmlBody = buildBodyDiv(body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -112,12 +142,14 @@ var MailSend = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return err
}
} 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
@@ -132,7 +164,6 @@ var MailSend = common.Shortcut{
return fmt.Errorf("failed to build EML: %w", err)
}
mailboxID := resolveComposeMailboxID(runtime)
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
@@ -145,14 +176,11 @@ 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)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
return nil
},
}

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,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"regexp"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/signature"
)
var MailSignature = common.Shortcut{
Service: "mail",
Command: "+signature",
Description: "List or view email signatures with default usage info.",
Risk: "read",
Scopes: []string{"mail:user_mailbox:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "from", Default: "me", Desc: "Mailbox address (default: me)"},
{Name: "detail", Desc: "Signature ID to view rendered details. Omit to list all signatures."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := runtime.Str("from")
if mailboxID == "" {
mailboxID = "me"
}
return common.NewDryRunAPI().
Desc("List or view email signatures").
GET(mailboxPath(mailboxID, "signatures"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
mailboxID := runtime.Str("from")
if mailboxID == "" {
mailboxID = "me"
}
detailID := runtime.Str("detail")
resp, err := signature.ListAll(runtime, mailboxID)
if err != nil {
return err
}
if detailID != "" {
return executeSignatureDetail(runtime, resp, detailID, mailboxID)
}
return executeSignatureList(runtime, resp)
},
}
func executeSignatureList(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse) error {
// Build default signature ID maps from usages.
sendDefaults := map[string]bool{}
replyDefaults := map[string]bool{}
for _, usage := range resp.Usages {
if usage.SendMailSignatureID != "" && usage.SendMailSignatureID != "0" {
sendDefaults[usage.SendMailSignatureID] = true
}
if usage.ReplySignatureID != "" && usage.ReplySignatureID != "0" {
replyDefaults[usage.ReplySignatureID] = true
}
}
lang := resolveLang(runtime)
items := make([]map[string]interface{}, 0, len(resp.Signatures))
for _, sig := range resp.Signatures {
item := map[string]interface{}{
"id": sig.ID,
"name": sig.Name,
"type": string(sig.SignatureType),
}
if len(sig.Images) > 0 {
item["images"] = len(sig.Images)
}
// Short content preview (rendered for TENANT).
rendered := signature.InterpolateTemplate(&sig, lang, "", "")
item["content_preview"] = contentPreview(rendered, 200, lang)
if sendDefaults[sig.ID] {
item["is_send_default"] = true
}
if replyDefaults[sig.ID] {
item["is_reply_default"] = true
}
items = append(items, item)
}
runtime.OutFormat(
map[string]interface{}{"signatures": items},
&output.Meta{Count: len(items)},
nil,
)
return nil
}
func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse, sigID, mailboxID string) error {
var sig *signature.Signature
for i := range resp.Signatures {
if resp.Signatures[i].ID == sigID {
sig = &resp.Signatures[i]
break
}
}
if sig == nil {
return output.ErrValidation("signature not found: %s", sigID)
}
lang := resolveLang(runtime)
detail := map[string]interface{}{
"id": sig.ID,
"name": sig.Name,
"type": string(sig.SignatureType),
}
// Usage info.
for _, usage := range resp.Usages {
if usage.SendMailSignatureID == sig.ID {
detail["is_send_default"] = true
}
if usage.ReplySignatureID == sig.ID {
detail["is_reply_default"] = true
}
}
// Images metadata — output the full structure from API.
if len(sig.Images) > 0 {
detail["images"] = sig.Images
}
// Template variables (TENANT signatures): show resolved values.
if sig.HasTemplateVars() {
vars := make(map[string]string, len(sig.UserFields))
for key, field := range sig.UserFields {
vars[key] = field.Resolve(lang)
}
detail["template_vars"] = vars
}
// Rendered content preview.
rendered := signature.InterpolateTemplate(sig, lang, "", "")
detail["content_preview"] = contentPreview(rendered, 200, lang)
runtime.Out(detail, nil)
return nil
}
// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us").
func resolveLang(runtime *common.RuntimeContext) string {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "zh_cn"
}
cfg, err := runtime.Factory.Config()
if err != nil {
return "zh_cn"
}
app := multi.FindApp(cfg.ProfileName)
if app == nil {
return "zh_cn"
}
switch app.Lang {
case "en":
return "en_us"
case "ja":
return "ja_jp"
default:
return "zh_cn"
}
}
// contentPreview converts HTML to a compact plain-text preview.
// <img> tags become a localized image placeholder, all other tags become
// spaces, then consecutive whitespace is collapsed. Result is truncated
// to maxLen runes.
func contentPreview(html string, maxLen int, lang string) string {
placeholder := "[image]"
if strings.HasPrefix(lang, "zh") {
placeholder = "[图片]"
}
imgRe := regexp.MustCompile(`<img[^>]*>`)
s := imgRe.ReplaceAllString(html, placeholder)
// Strip remaining tags, replacing each with a space.
var result strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
result.WriteByte(' ')
case r == '>':
inTag = false
case !inTag:
result.WriteRune(r)
}
}
// Collapse whitespace and trim.
text := strings.Join(strings.Fields(result.String()), " ")
text = strings.TrimSpace(text)
runes := []rune(text)
if len(runes) <= maxLen {
return text
}
return string(runes[:maxLen]) + "..."
}

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