Compare commits

..

25 Commits

Author SHA1 Message Date
liangshuo-1
686c91dc71 chore(release): v1.0.23 (#737)
Change-Id: I48f780acac9731585aeec0a51f5b403a00804dbc
2026-04-30 18:04:10 +08:00
河伯
cfd89e0e28 feat(doc): warn when callout uses type= without background-color (#467)
* feat(doc): expand callout type= shorthand into background-color and border-color

When users write <callout type="warning" emoji="📝"> without an explicit
background-color, the Feishu doc renders the block with no color. This
commit adds fixCalloutType() which maps the semantic type= attribute to
the corresponding background-color/border-color pair accepted by create-doc.

- warning → light-yellow/yellow
- info/note → light-blue/blue
- tip/success/check → light-green/green
- error/danger → light-red/red
- caution → light-orange/orange
- important → light-purple/purple

Explicit background-color or border-color attributes are always preserved.
The fix is applied via prepareMarkdownForCreate() in both +create and
+update paths, and also inside fixExportedMarkdown() for round-trip fidelity.

* refactor(doc): replace silent callout type→color injection with hint output

Per reviewer feedback (SunPeiYang996), silently rewriting user Markdown is
the wrong layer for this adaptation. The type→color mapping is not part of
the Feishu spec, and covert transforms make debugging harder.

Replace fixCalloutType() (which rewrote the Markdown) with WarnCalloutType()
which leaves the Markdown unchanged and instead writes a hint line to stderr
for each callout tag that has type= but no background-color, telling the user
the recommended explicit attributes to add:

  hint: callout type="warning" has no background-color; consider: background-color="light-yellow" border-color="yellow"

Also fixes CodeRabbit feedback: the type= regex now accepts both single-quoted
and double-quoted attribute values (type='warning' and type="warning").

* fix(doc): harden background-color detection in WarnCalloutType

CodeRabbit flagged that the previous strings.Contains(attrs,
"background-color=") check missed forms like 'background-color =
"light-red"' with whitespace around the equals sign. Replace with a
regex that tolerates optional whitespace, and add a regression test.

* fix(doc): close real review gaps left over after rebase

PR #467's review thread had three substantive comments
(`fangshuyu-768`, 2026-04-21) that the prior reply messages claimed
were fixed in commit 7d4b556 — but that commit no longer exists on the
branch (lost in a rebase / squash), and the head still ships the
original buggy code. This commit makes the fixes real.

Three behavior fixes in shortcuts/doc/markdown_fix.go:

1. (#5) Tighten the type= and background-color= regex anchors. \b sits
   at any word/non-word boundary, and `-` is a non-word char, so
   `\btype=` also matched the suffix of `data-type=` — a tag like
   `<callout data-type="warning">` would emit a bogus light-yellow
   hint. Switched both regexes to `(?:^|\s)…` so a real attribute
   separator is required. The same anchor on background-color closes
   the symmetric case where a `data-background-color=` attribute
   would silently suppress the real hint.

2. (#4) WarnCalloutType is now a fence-aware line walker. Previously
   the regex ran over the entire markdown body, so a callout sample
   inside a documentation code fence (```markdown … ```) would
   generate a phantom stderr hint every time the docs mentioned the
   feature. The walker tracks fence state via the existing
   codeFenceOpenMarker / isCodeFenceClose helpers from
   docs_update_check.go, which handle both backtick and tilde fences
   per CommonMark §4.5.

3. (#3) Drop the ReplaceAllStringFunc-as-iterator pattern. The
   previous code routed callout iteration through a rewrite primitive
   whose rebuilt-string return value was discarded, then ran the same
   regex a second time inside the callback to recover the capture
   groups. New scanCalloutTagsForWarning helper uses
   FindAllStringSubmatch — one pass, no thrown-away allocation,
   intent matches the surface (read-only scan, not a mutator).

Tests: 5 new TestWarnCalloutType subtests pin each contract:

- data-type attribute does not trigger hint (#5)
- data-background-color does not suppress hint (#5, symmetric)
- callout inside backtick fence emits no hint (#4)
- callout inside tilde fence emits no hint (#4)
- callout after fence close still emits hint (#4, fence-state reset)

All 14 TestWarnCalloutType cases pass; go vet / golangci-lint
--new-from-rev=origin/main both clean.
2026-04-30 17:51:08 +08:00
zhouyue-bytedance
ac4c34f2ad feat: support file-name for drive export (#685)
* feat: support file-name for drive export

* test: cover drive export file-name metadata
2026-04-30 17:30:23 +08:00
zgz2048
3ed691b25c feat(base): add markdown output for record reads (#726)
* feat(base): add record read SOP guidance

1. Add a unified lark-base record read SOP for get/search/list routing, field projection, temporary view querying, pagination, matrix result binding, and link field reads.
2. Inline command-focused parameter guidance into +record-get, +record-search, and +record-list help, including examples, JSON shape, view scope, projection, and limit constraints.
3. Preserve base shortcut flag order in help output and add tests covering record read help guidance.
4. Remove the single-method record read skill references in favor of the unified SOP.

* test(base): remove stale record list fixture

* fix(base): scan record markdown output

* fix(base): fallback record markdown output

* fix(base): unify base token wording in shortcuts and skills
2026-04-30 17:09:17 +08:00
fangshuyu-768
30ad38d4b6 feat(drive): add +pull shortcut for one-way Drive → local mirror (#696)
* feat(drive): add +pull shortcut to mirror a Drive folder onto local

Adds `drive +pull`, a one-way Drive → local mirror command. It
recursively lists --folder-token, downloads each type=file entry
into --local-dir at the matching relative path, and optionally
deletes local files absent from the remote (mirror semantics).

Implementation notes:

- Listing recurses through subfolders with the standard 200-page
  pagination loop. Online docs (docx, sheet, bitable, mindnote,
  slides) and shortcuts are skipped since there is no equivalent
  local binary to write back. Folder tree is reproduced under
  --local-dir, with parent directories auto-created by FileIO.Save.

- Per-file --if-exists=overwrite (default) | skip controls how
  pre-existing local files are treated; the framework's enum guard
  rejects any other value.

- --delete-local is the only destructive flag and is bound to --yes
  in Validate: --delete-local without --yes is rejected upfront so
  no listing or download even runs. --delete-local --yes performs
  downloads first, then walks --local-dir and removes regular files
  not present in the remote map. This matches the spec doc's
  "high-risk-write" intent for --delete-local without making the
  default pull path require confirmation.

- --local-dir is funneled through validate.SafeLocalFlagPath so
  errors reference --local-dir instead of the framework default
  --file. FileIO().Stat then enforces existence and IsDir.

- Scopes: drive:drive.metadata:readonly + drive:file:download. The
  broader drive:drive is disabled by enterprise policy in some
  tenants.

- Listing helper (drivePullListRemote) is duplicated locally rather
  than reused from drive_status.go because that change is still in
  open PR #692; once it merges, both can be lifted into a shared
  drive package helper. TODO marker is left in the code.

Tests cover six unit scenarios (happy-path with nested subfolder +
docx skipping, --if-exists=skip, --delete-local rejection without
--yes, --delete-local --yes deletes orphans, absolute-path
rejection, bad enum) and four E2E dry-run scenarios (request shape,
absolute path rejection, --delete-local --yes guard, missing
required flag).

* docs(skills): document drive +pull in lark-drive skill

Adds references/lark-drive-pull.md covering parameters, output schema
(summary + per-item action breakdown), the type=file scoping rule,
the --if-exists policy matrix, and the --delete-local + --yes safety
contract. Calls out the network-traffic caveat (pull is full-download,
unlike +status which only fetches when both sides have the file) and
the cwd boundary on --local-dir.

Wires +pull into the Shortcuts table in SKILL.md.

* fix(drive): walk +pull on canonical absolute root to close symlink/.. escape

Same root cause as the +status fix: --local-dir was validated through
SafeLocalFlagPath but the walk used the user-supplied raw string.
SafeLocalFlagPath returns the original value (the canonical form is
discarded), and SafeInputPath itself relies on filepath.Clean for
normalization, which shrinks "link/.." to "." purely as string
manipulation. The kernel then resolves "link/.." through the symlink
target's parent at walk time, putting the traversal outside cwd.

For +pull the bug is more dangerous than for +status because it
travels through --delete-local --yes — a raw walk would let the
delete pass land on files outside cwd.

Fix:
- In Execute, resolve --local-dir via validate.SafeInputPath to get a
  canonical absolute path, and resolve "." the same way for cwd.
- Convert the resolved root back to a cwd-relative form
  (filepath.Rel) for download targets so FileIO.Save's existing
  SafeOutputPath check (which rejects absolute paths) still applies.
- For --delete-local, walk the canonical absolute root, then delete
  via the absolute path. Both values come from the validated
  safeRoot, so kernel path resolution cannot redirect a delete to a
  file outside the canonical subtree.
- drivePullWalkLocal now returns absolute paths instead of rel paths;
  the caller computes the rel_path via filepath.Rel against safeRoot
  for output / remote-set membership checks.

Adds TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef as a
regression: it stages an "escape" sibling directory containing a
sentinel file, adds a "link" symlink in cwd pointing into it, and
runs +pull --delete-local --yes against an empty remote with
--local-dir "link/..". The sentinel must survive (proving --delete
did not escape) and the in-cwd file must be removed (proving the
walk did run).

* test(drive): pin walker / download behavior on +pull symlink corner cases

Adds three regressions on top of the canonical-root walk fix:

- TestDrivePullSkipsSymlinkInsideRoot: a child symlink inside the
  validated root pointing to a sibling temp dir. Under
  --delete-local --yes with an empty remote, the sentinel inside the
  target must survive (walker did not follow the child symlink) and
  the in-cwd file must be deleted (walker did run).

- TestDrivePullSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing at one of its ancestors. The walk must terminate so the
  test does not hang on the per-test timeout.

- TestDrivePullDownloadDoesNotEscapeViaSymlinkParentRef: pins the
  download half of the fix. With --local-dir "link/.." the canonical
  root resolves to cwd, so the remote file must land in cwd, not
  inside the symlink target's parent. The preexisting sentinel inside
  the escape directory must remain untouched.

* fix(drive): +pull --delete-local must not unlink local files shadowed by online docs

CodeRabbit (PR #696) flagged that the --delete-local pass treated any
local path missing from `remoteFiles` as orphaned, but `remoteFiles` only
records type=file entries. If Drive held a docx/sheet/shortcut at the
same rel_path as a local file, the local file would be unlinked even
though Drive still owned that path.

drivePullListRemote now returns two views:

  - files:    rel_path -> file_token, type=file only (download/skip set)
  - allPaths: every entry's rel_path regardless of type

The download loop continues to consume `files`; the --delete-local pass
consults `allPaths`, so an online-doc shadow of a local filename keeps
the local file safe.

Also routes the local walk and the delete through the vfs abstraction
(vfs.ReadDir + vfs.Remove) instead of filepath.WalkDir + os.Remove.
This drops the //nolint:forbidigo justifications and lines up with how
internal/keychain and internal/registry already do filesystem I/O. The
recursive vfs.ReadDir walker preserves the same "do not follow child
symlinks" semantics that filepath.WalkDir gave us, so the canonical-root
escape protections in 240b772 stay intact.

Adds TestDrivePullDeleteLocalPreservesLocalFileShadowedByOnlineDoc as a
direct regression: Drive serves keep.txt (file) plus notes.docx (docx),
local has both keep.txt and a hand-edited notes.docx; --delete-local
--yes must download keep.txt, leave notes.docx untouched, and report
deleted_local=0.

* fix(drive): count +pull delete failures in summary.failed

CodeRabbit (PR #696) flagged that both delete_failed branches in the
--delete-local pass appended an item but left the `failed` counter at
zero, so the JSON summary could legitimately report `"failed": 0` after
a partially-failed mirror. Increment failed in both branches (the
filepath.Rel error path and the vfs.Remove error path) so summary.failed
reflects every item flagged delete_failed in items[].

Adds TestDrivePullDeleteLocalCountsFailureInSummary, which forces
vfs.Remove to fail by chmod-ing the local dir 0o555 right before the
run and restoring 0o755 in t.Cleanup so t.TempDir teardown still works.

* fix(drive): swap +pull walk/remove back to filepath/os to satisfy depguard

The previous fix-up commits used vfs.ReadDir + vfs.Remove inside the
+pull shortcut, which depguard's "shortcuts-no-vfs" rule rejects:
shortcuts cannot import internal/vfs directly. CI lint failed on the
import line.

Restore the same pattern used in drive_status.go and the prior +pull
walker:

- filepath.WalkDir to enumerate files under the canonical absolute
  root, gated by //nolint:forbidigo with a comment explaining why.
- os.Remove for the actual delete, also gated by //nolint:forbidigo.

The canonical-root safety still holds: validate.SafeInputPath bounds
the walk root inside cwd before WalkDir runs, and WalkDir's default
"do not follow child symlinks" policy is preserved. The two earlier
fixes (drivePullListRemote returning allPaths so online-doc shadows
do not look orphaned, and incrementing failed on delete_failed) stay
in place.

`go test ./shortcuts/drive/...` and `golangci-lint run
--new-from-rev=origin/main` are both clean.

* fix(drive): record remote folder rel_path in +pull allPaths

Follow-up to 45fe4e3. The folder branch in drivePullListRemote merged
descendant rel_paths into allPaths but never recorded the folder's own
rel_path, so a local regular file with the same name as a remote
folder still looked orphaned and got unlinked under --delete-local.

Adds the missing allPaths[rel] for the folder case and a regression:
TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder
stages a Drive containing a folder named shadow alongside a
downloadable file, with the local side holding a regular file named
shadow; --delete-local --yes must download keep.txt and leave the
shadow file untouched.

* fix(drive): +pull pagination + dir/file conflict + skill doc symlink claim

Codex review on PR #696 surfaced three issues; addressed in one go:

1. drivePullListRemote only honored next_page_token. The shared
   common.PaginationMeta helper accepts both page_token and
   next_page_token; switched +pull over so a backend reply using
   page_token no longer makes the lister stop at page 1 (which would
   silently drop later remote files from both download and
   --delete-local).

2. --if-exists=skip swallowed mirror conflicts. The skip/overwrite
   branch only checked Stat success, so a local directory shadowing a
   remote regular file was reported as action=skipped. Now Stat's
   IsDir() is checked first; the conflict surfaces as action=failed
   with a message naming the directory, under both --if-exists=skip
   and --if-exists=overwrite, and increments summary.failed.

3. Skill doc told callers to soft-link the target into cwd if they
   wanted to pull from outside cwd. That is wrong: SafeInputPath
   evaluates symlinks before the cwd check, so a symlink pointing
   out-of-tree is rejected. Replaced the bogus shortcut with the
   actually viable options (switch the agent working directory,
   physically move/copy the target, or skip the comparison).

Two new regressions:

- TestDrivePullSurfacesDirectoryFileMirrorConflict — table test over
  both policies asserting failed=1, no skipped, action=failed, plus
  the 'is a directory' hint in the error message.

- TestDrivePullPaginationHandlesPageTokenField — first page returns
  page_token (not next_page_token) with has_more=true; asserts both
  pages are fetched and both files land on disk.

* fix(drive): +pull exits non-zero on item failures; gate --delete-local

Two PR-696 review fixes:

- Item-level failures (download error, dir/file conflict, delete error)
  now surface as a structured partial_failure ExitError instead of a
  success envelope with summary.failed > 0. Exit code becomes non-zero
  and error.detail still carries the {summary, items[]} payload, so
  AI / script callers can detect the failure via the exit code without
  reaching into the JSON body.

- A failed download pass now skips the --delete-local walk entirely.
  Previously +pull would continue removing local-only files even when
  the download phase had partially failed, leaving the mirror in a
  half-synced state (some Drive files missing locally AND some
  local-only files unlinked). Re-runs after fixing the download error
  recover cleanly.

Skill doc / shortcut description / flag desc updated to call the
operation a one-way file-level mirror, since --delete-local only
unlinks regular files and does not prune empty local directories left
behind by remote folder deletes (true directory-level mirroring is
explicitly out of scope).

Tests: existing dir/file-conflict and delete-failure cases now assert
the partial_failure ExitError shape; new test covers the
"download fails => --delete-local skipped" gating contract.

* refactor(drive): consolidate folder-listing helpers into listRemoteFolder

Closes the post-#692 / post-#709 TODO that lived in drive_pull.go (and
the matching note in drive_push.go): both #692 and #709 are now on main,
so the three near-identical recursive Drive folder listers can collapse
into one.

New shared helper in shortcuts/drive/list_remote.go:

  driveRemoteEntry { FileToken, Type, RelPath }
  listRemoteFolder(ctx, runtime, folderToken, relBase) -> map[rel]entry

Returns one entry per Drive item (every type), keyed by rel_path.
Subfolders are descended into and the folder's own entry is recorded so
callers can reason about "this rel_path is occupied by a folder"
without re-listing. Pagination via common.PaginationMeta is unchanged.

Each shortcut now derives its own per-shortcut view from the unified
listing:

  - drive_status.go: collapses to remoteFiles (Type=="file" -> token) for
    the content-hash diff.
  - drive_pull.go: derives remoteFiles (Type=="file") for the download
    set, plus remotePaths (every rel_path) as the --delete-local guard.
  - drive_push.go: derives remoteFiles (Type=="file") for upload /
    overwrite / orphan-delete, plus remoteFolders (Type=="folder") for
    the create_folder cache. drivePushRemoteEntry was a duplicate of
    driveRemoteEntry's first two fields and is dropped; the few call
    sites that read .FileToken keep working unchanged.

Per-shortcut copies removed:
  - drive_status.go: listRemoteForStatus, joinRelStatus,
    driveStatusListPageSize/FileType/FolderType
  - drive_pull.go: drivePullListRemote, drivePullJoinRel,
    drivePullListPageSize/FileType/FolderType
  - drive_push.go: drivePushListRemote, drivePushJoinRel,
    drivePushListPageSize/FileType/FolderType, drivePushRemoteEntry

drive_push_test.go's TestDrivePushHelpersRelPath is retargeted at the
shared joinRelDrive; the docstrings on the same-name-conflict tests
were tweaked to refer to "the remoteFiles view" instead of the
just-removed drivePushListRemote.

Net diff: +1 new file, -207 net lines across the four touched files.
All existing unit + e2e dry-run tests pass without behavioral change;
the rel_path / pagination / type-filter contracts each shortcut depends
on are preserved by construction.
2026-04-30 17:07:59 +08:00
calendar-assistant
4fab062219 docs: clarify minutes file-to-notes routing (#732)
Change-Id: If768200b329c5e255b13c1992b8c57d1fd8ec518
2026-04-30 16:59:22 +08:00
wittam-01
f27b8fdf40 feat: add markdown shortcuts and skill docs (#704)
Change-Id: Iced88525deb10b014b755ec68bd9a8ae6a935143
2026-04-30 15:47:36 +08:00
liangshuo-1
c100ca049e feat(cmdutil): support @file for params and data (#724)
* feat(cmdutil): support @file for --params/--data (issue #705)

Inline JSON values for --params/--data are mangled by Windows
PowerShell 5's CommandLineToArgvW. Stdin (-) was the only escape
hatch but supports just one flag at a time.

Extend ResolveInput to accept @<path> (read JSON from a file) and
@@... (escape for a literal @-prefixed value), mirroring the
shortcuts framework's resolveInputFlags semantics. With this, both
--params and --data can be sourced from files in the same call,
sidestepping shell quoting on every platform.

- internal/cmdutil/resolve.go: add @path / @@ handling, trim file
  content like stdin does, error on empty path or empty file
- internal/cmdutil/resolve_test.go: cover file read, whitespace
  trim, missing file, empty path, empty content, @@ escape, plus
  ParseJSONMap / ParseOptionalBody integration through @file
- cmd/api/api.go, cmd/service/service.go: update --params/--data
  help text to mention @file

Change-Id: I366aa0f5783fbec6f05403f7f542505098a98c82

* refactor(cmdutil): route @file through fileio.FileIO abstraction

The first cut of @file support called os.ReadFile directly inside
ResolveInput, bypassing the codebase's fileio.FileIO abstraction
(SafeInputPath validation, pluggable provider). That diverged from
how every other file-reading path works: BuildFormdata for --file
uploads and the shortcuts framework's resolveInputFlags both go
through fileio.FileIO.Open with explicit fileio.ErrPathValidation
handling.

Re-route @file through the same path:

- ResolveInput, ParseJSONMap, ParseOptionalBody now take a
  fileio.FileIO; @path uses fileIO.Open which goes through
  SafeInputPath (control-char rejection, abs-path rejection,
  symlink-escape check) — same security posture as --file
- cmd/api and cmd/service callsites pass
  Factory.ResolveFileIO(ctx); the upload path now reuses the
  resolved fileIO instead of resolving twice
- Path-validation errors surface as
  `--params: invalid file path "...": ...` distinct from
  `--params: cannot read file "...": ...` for genuine I/O errors
- Nil fileIO with an @path returns a clear
  "file input (@path) is not available" error
- Tests use localfileio.LocalFileIO with TestChdir(t, dir),
  matching the existing fileupload_test.go pattern; absolute-path
  rejection and nil-fileIO are covered

This makes the feature behave identically under any FileIO
provider (including server mode) instead of being silently bound
to the local filesystem.

Change-Id: I878c4e8fb03f43f1f19afad75ec3af9cdab7a7f9

* refactor(cmdutil): share at-file input handling

Change-Id: I92a6eb6ea8fd02054bf8f4925cd81807449d5e51
2026-04-30 15:34:45 +08:00
fangshuyu-768
4d68e09537 feat(drive): add +push shortcut for one-way local → Drive mirror (#709)
* feat(drive): add +push shortcut for one-way local → Drive mirror

Mirrors a local directory onto a Drive folder: walks --local-dir,
recursively lists --folder-token, mirrors local subdirectory structure
(including empty dirs) onto Drive via create_folder, and for each
rel_path uploads new files, overwrites already-present files, or skips
them per --if-exists. With --delete-remote --yes, any Drive type=file
entry absent locally is removed; Lark native cloud docs (docx/sheet/
bitable/mindnote/slides) and shortcuts are never overwritten or deleted.

Overwrite hits POST /open-apis/drive/v1/files/upload_all with the
existing file_token in the form body and the response's `version` is
propagated to items[].version, mirroring the markdown +overwrite
contract. Files >20MB fall back to the 3-step
upload_prepare/upload_part/upload_finish path with a single shared fd
reused via io.NewSectionReader per block.

Output is a {summary, items[]} envelope; items[].action is one of
uploaded / overwritten / skipped / folder_created / deleted_remote /
failed / delete_failed.

--delete-remote is bound to --yes upfront in Validate, same pattern as
+pull's --delete-local: a stray flag never silently deletes anything.
Path safety reuses the canonical-root walk + SafeInputPath mechanics
from the sibling +status / +pull commands.

Scopes: drive:drive.metadata:readonly + drive:file:upload +
space:folder:create. space:document:delete is intentionally NOT in the
default set — the framework's pre-flight scope check would otherwise
block plain pushes and dry-runs for callers that haven't granted delete;
--delete-remote --yes relies on the runtime DELETE call to surface
missing_scope. The skill ref calls out the scope so users running
mirror sync can grant it upfront.

13 unit tests cover the upload/overwrite/skip/delete matrix, online-doc
protection, same-name conflict between local file and native cloud doc,
empty-directory mirroring, multipart, scope/path validation, and helper
correctness. 4 dry-run e2e tests pin the request shape.

* fix(drive +push): address review — failure semantics, default skip, scope pre-check, mirror wording

- Item-level failures now bump the exit code via output.ErrBare(ExitAPI)
  while keeping the structured items[] envelope on stdout. The
  --delete-remote phase is skipped entirely when any upload / overwrite /
  folder step fails, so a partial upload never proceeds to delete remote
  orphans (a half-synced state).
- Default --if-exists flipped from "overwrite" to the safer "skip": the
  upload_all overwrite-version protocol field is still rolling out, so
  the default no longer fails a first push against a pre-populated
  folder. Callers must opt into "overwrite" explicitly.
- --delete-remote --yes now triggers a conditional space:document:delete
  scope pre-check in Validate via the new RuntimeContext.EnsureScopes
  helper, so a missing grant fails the run before any upload — instead
  of after the upload phase, which would leave orphans uncleaned.
- Description, Tips and skill doc rewritten to call this a file-level
  mirror (not a directory mirror): the command does not remove
  remote-only directories or close gaps in directory structure that
  exists only on Drive.

Tests:
- new TestDrivePushDefaultsToSkipForExistingRemote pins the new default
- new TestDrivePushSkipsDeleteAfterUploadFailure pins the half-sync
  guard and the non-zero exit on item-level failure
- new TestDrivePushExitsZeroOnCleanRun pins the inverse
- existing tests that relied on the old overwrite default now opt in
  explicitly with --if-exists=overwrite
- TestDrivePushOverwriteWithoutVersionFails updated to assert
  *output.ExitError with Code=ExitAPI
- new TestDrive_PushDryRunAcceptsDeleteRemoteWithYes (e2e) symmetric to
  the existing reject-without-yes test, pinning that EnsureScopes is a
  silent no-op when the resolver has no scope metadata

* fix(drive +push): close remaining CodeRabbit comments

Three small follow-ups on the +push review thread that were still
open after the earlier failure-semantics / default-skip / scope
pre-check fix:

- drivePushUploadAll now extracts data.file_token before checking
  larkCode, and surfaces the returned token on the partial-success
  path (non-zero code + non-empty file_token). Without this, a backend
  response where bytes already landed but code != 0 would force the
  caller to fall back to entry.FileToken and silently lose the actual
  Drive token, defeating the overwrite-error token-stability handling
  in Execute.
- TestDrivePushOverwriteWithoutVersionFails switched from "tok_keep"
  to "tok_keep_new" in the upload_all stub and now asserts that the
  returned token (not entry.FileToken) lands in items[].file_token —
  pins the contract that a regression to the fallback branch would
  otherwise pass silently.
- New TestDrivePushOverwritePartialSuccessSurfacesReturnedToken pins
  the new partial-success branch end-to-end.
- drive_push_dryrun_test.go: tightened the three Validate / cobra
  rejections from `exit != 0` to exact codes — `exit == 2` for the
  two Validate-stage rejections (--local-dir absolute,
  --delete-remote without --yes), `exit == 1` for the cobra
  required-flag check (--folder-token missing). Locks in failure
  classification so a regression that misroutes the error layer
  doesn't slip through.
2026-04-30 15:00:44 +08:00
fangshuyu-768
a3bbe00ee0 feat(drive): add +status shortcut for content-hash diff (#692)
* feat(drive): add +status shortcut for content-hash diff

Adds `drive +status`, a read-only diff primitive that walks --local-dir,
recursively lists --folder-token, and reports four buckets — new_local,
new_remote, modified, unchanged — by SHA-256 content hash.

Implementation notes:

- Drive's list/metas APIs do not expose a content hash, so files
  present on both sides are downloaded via DoAPIStream and hashed in
  memory (sha256 + io.Copy, no disk write). Files only on one side are
  not fetched. The command stays Risk: "read".

- Only Drive entries with type=file participate. Online docs (docx,
  sheet, bitable, mindnote, slides) and shortcuts are skipped — there
  is no equivalent local binary to hash against.

- --local-dir is funneled through the framework's
  validate.SafeLocalFlagPath helper so that absolute paths and any ..
  that escapes cwd are rejected with --local-dir in the error message
  (rather than the internal default --file). FileIO().Stat() then
  enforces existence and the IsDir check.

- Local walk uses filepath.WalkDir behind a //nolint:forbidigo comment.
  The runtime FileIO interface has no walker today and shortcuts can't
  import internal/vfs; SafeInputPath has already bounded the walk root
  inside cwd, so the bare walk is acceptable until a runtime-level
  walker lands.

- Scopes: drive:drive.metadata:readonly (list folders) +
  drive:file:download (fetch files for hashing). The broader
  drive:drive scope is disabled by enterprise policy in some tenants;
  this narrower pair was verified end-to-end.

Tests cover the four-bucket categorization with a nested subfolder and
docx/shortcut filtering, plus validation errors for missing local-dir,
non-directory local-dir, and absolute-path local-dir.

* docs(skills): document drive +status in lark-drive skill

Adds references/lark-drive-status.md covering parameters, output
schema, the type=file scoping rule, and the network-traffic caveat
(hash is streamed in memory, but bytes still cross the wire).

Notes that --local-dir is bounded to cwd by the CLI's path validation,
and that when a user wants to compare a directory outside cwd the
agent should ask the user to relocate or to switch the agent's working
directory rather than `cd`-ing on its own.

Wires +status into the Shortcuts table in SKILL.md.

* test(drive): cover --folder-token validation and add +status dry-run E2E

Addresses two CodeRabbit review comments on PR #692:

- Adds TestDriveStatusRejectsEmptyFolderToken and
  TestDriveStatusRejectsMalformedFolderToken so the Validate-stage
  required-check and the ResourceName format guard for --folder-token
  are exercised, not just --local-dir.

- Adds tests/cli_e2e/drive/drive_status_dryrun_test.go which drives
  the real binary in dry-run mode and asserts:

  * the request shape (GET /open-apis/drive/v1/files with
    folder_token in the dry-run envelope), plus the description text,
  * --local-dir absolute paths are rejected by Validate (which still
    runs under --dry-run) with --local-dir surfaced in the message,
  * cobra's required-flag enforcement rejects a missing
    --folder-token before any custom validation.

* fix(drive): walk +status on canonical absolute root to close symlink/.. escape

Reported in PR review: --local-dir was validated through
SafeLocalFlagPath, but the actual walk used the user-supplied raw
string. SafeLocalFlagPath returns the original value (it only checks
the path through SafeInputPath and discards the canonical form), and
SafeInputPath itself relies on filepath.Clean for path normalization.
filepath.Clean shrinks "link/.." to "." purely as string manipulation,
so the validator sees a path inside cwd. The kernel, however, resolves
"link/.." through the symlink target's parent — which is outside cwd
and is what filepath.WalkDir actually traverses.

Fix: in Execute, resolve --local-dir via validate.SafeInputPath to
get the canonical absolute path (this one fully evaluates symlinks
across the entire path), and walk that path. Each absolute walk hit
is converted to a cwd-relative form via filepath.Rel against
validate.SafeInputPath(".") so FileIO.Open's existing SafeInputPath
guard (which rejects absolute paths) still applies.

Adds TestDriveStatusDoesNotEscapeViaSymlinkParentRef as a regression:
it stages an "escape" sibling directory containing a sentinel file,
adds a "link" symlink in cwd pointing into the escape directory, and
runs +status with --local-dir "link/..". Without this fix, the raw
walk visits the sentinel and leaks it into new_local; with the fix,
the walk stays inside the canonical cwd.

A standalone repro confirms the underlying behavior: raw
filepath.WalkDir("link/..", ...) traversed dozens of unrelated files
in the kernel-resolved parent directory; walking the canonical root
visits only the legitimate cwd contents.

* test(drive): pin walker behavior on child / circular symlinks for +status

Adds two corner-case regressions to back up the canonical-root walk fix:

- TestDriveStatusSkipsSymlinkInsideRoot: a child symlink under
  --local-dir that points to a sibling temp dir outside cwd. WalkDir's
  default policy must report it as a non-regular entry so the callback
  skips it, and the sentinel inside the target must not surface in
  new_local. This pins the contract our caller relies on (walk
  declines to follow child symlinks even when the canonical root
  resolves cleanly).

- TestDriveStatusSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing back at one of its ancestors. The walk must terminate and
  surface the legitimate sibling file; if WalkDir ever followed the
  loop, the per-test timeout would catch it.

* fix(drive): close +status review gaps from Codex (pagination, doc, live E2E)

Three independent fixes flagged on PR #692:

1. Route the recursive Drive folder listing through common.PaginationMeta
   instead of reading next_page_token directly. The shared helper accepts
   both page_token and next_page_token, matching what okr/im already do
   and keeping +status safe against a backend field rename. Adds
   TestDriveStatusPaginatesRemoteListing, which serves a 2-page response
   where page 1 advertises the cursor as next_page_token and page 2 as
   page_token; either spelling alone would silently drop one page.

2. The skill doc previously suggested "or symlink the target into cwd"
   as a workaround for cwd-relative --local-dir. SafeInputPath calls
   filepath.EvalSymlinks before checking isUnderDir(canonicalCwd), so
   any symlink whose final target sits outside cwd still gets rejected
   as `unsafe file path`. Rewrite the section so agents stop steering
   users into a path that always errors out.

3. Add tests/cli_e2e/drive/drive_status_workflow_test.go — the live
   E2E that AGENTS.md requires for new shortcuts. Seeds a real Drive
   folder with three uploaded files (unchanged.txt, modified.txt,
   remote-only.txt), seeds a local tree with matching/diverging
   content plus a local-only.txt, runs +status, and asserts each of
   the four buckets contains exactly the file we expect with the
   right file_token. Cleanup of every uploaded file plus the parent
   folder is registered through the existing best-effort cleanup
   helpers. Coverage table bumped: drive +status moves to ✓ and the
   denominator goes from 28→29 to account for the new shortcut.

Codex also flagged the local-side filepath.WalkDir as a vfs-bypass.
Investigated: the depguard rule shortcuts-no-vfs explicitly forbids
shortcuts from importing internal/vfs (see commit c1b0bed on the
+pull branch where the same migration was rejected by CI). The
filepath.WalkDir + nolint:forbidigo pattern in walkLocalForStatus is
the lint-required convention until FileIO grows a walker, so leaving
it as-is.
2026-04-30 14:27:25 +08:00
calendar-assistant
0250054a90 feat(minutes): add media upload shortcut (#725)
Support minutes +upload to generate a minute from an uploaded media file token.

Change-Id: I59c0719a39541134e395a23262aea7f387105715

Co-authored-by: calendar-assistant <calendar-assistant@users.noreply.github.com>
2026-04-30 11:19:22 +08:00
SunPeiYang996
d7ee5b5769 feat: guide lark-doc v2 usage (#710)
## Summary
Add explicit guidance on the parent `docs` command so agents pick the right
lark-doc API version. Without this, agents that have an older lark-doc skill
installed can mistakenly mix v2 flags into a v1 flow.

## Changes
- Add `--api-version` help flag and a Tips section to `docs` so `lark docs --help`
  (and `--api-version v2`) explain when v2 should be used.
- Refresh the lark-doc skill references and `docs_fetch_v2` keyword flag
  description for clarity.
- Add `shortcuts/register_test.go` covering the new docs help wiring.

## Test Plan
- [x] Unit tests pass (`go test ./shortcuts/...`)
- [x] Manual local verification confirms the `lark docs --help` and
      `lark docs --help --api-version v2` commands work as expected

## Related Issues
- None

Change-Id: Id3b3196e6a069bb52f95a6fc679b8258313faf3d
2026-04-29 22:40:20 +08:00
liangshuo-1
b37adfd0ee chore(release): v1.0.22 (#719)
Change-Id: If383f91a8b934a4feec3ff6d371a3f2f6a94ec09
2026-04-29 20:04:06 +08:00
bytedance-zxy
082275f32b feat(task): add resource agent & agent_task_step_info (#693)
Change-Id: I3b2d8ee72361aee9b68a5bbbafcf594f220d3105
2026-04-29 19:13:05 +08:00
zero-my
2eb9fae575 Feat/task app members (#712)
* feat: support app task members by id

* docs: clarify task member id formats
2026-04-29 19:04:27 +08:00
sang-neo03
418192507e fix(install): make Windows zip extraction resilient (issue #603) (#713)
The Windows extraction step relied on `powershell -Command Expand-Archive`,
which fails when:
  - Microsoft.PowerShell.Archive (a script module) cannot be loaded due to
    PSModulePath shadowing (Store-installed pwsh injecting WindowsApps
    paths) or ExecutionPolicy Restricted (issue #603), or
  - the temp directory contains characters that corrupt PowerShell string
    parsing (e.g. a single quote in TEMP).

Switch to a two-tier extraction:
  1. Primary: Add-Type System.IO.Compression.FileSystem +
     [ZipFile]::ExtractToDirectory. Bypasses the PowerShell module system
     entirely. .NET 4.5+, available on Win 8 / Server 2012 by default and
     widely on Win 7 SP1.
  2. Fallback: Expand-Archive -LiteralPath, kept for the rare host without
     .NET 4.5 but with PS 5.0+ (e.g. Win 7 SP1 with WMF 5).

Both paths pass file paths through env vars ($env:LARK_CLI_ARCHIVE /
$env:LARK_CLI_DEST) so quoting / wildcard chars in the path can no longer
break command parsing. -LiteralPath ensures Expand-Archive treats the value
literally rather than as a wildcard pattern. $ErrorActionPreference='Stop'
makes non-terminating cmdlet errors propagate as non-zero exit codes.

Also drop `stdio: "ignore"` so the actual PowerShell error surfaces in the
postinstall log when both paths fail, instead of leaving users with
"Command failed: powershell ..." with no detail.

Verified on Windows 10 + PS 5.1:
  - Reproduced #603 with shadow Microsoft.PowerShell.Archive +
    Restricted ExecutionPolicy: original install.js fails, patched
    install.js succeeds.
  - Reproduced single-quote-in-TEMP path corruption: original fails,
    patched succeeds.
  - Fallback path verified end-to-end with primary forced to fail.
  - Normal-environment install: no regression.
2026-04-29 17:50:46 +08:00
liangshuo-1
7752afab96 fix(config/init): respect --brand flag in --new mode (#711)
* feat(contact +search-user): add --queries multi-name fanout

Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0

* fix(config/init): use parseBrand(opts.Brand) instead of hardcoded BrandFeishu in --new mode

The --new flag was ignoring the --brand flag and always passing BrandFeishu
to runCreateAppFlow. Now it correctly uses parseBrand(opts.Brand) to
respect the user's --brand parameter (e.g., --brand lark for international).

Change-Id: I1d4d78b3d586142b0210e6ceaeeb467b14e9c1a1
2026-04-29 17:13:47 +08:00
liangshuo-1
f7a56f38b1 feat(contact +search-user): add --queries multi-name fanout (#707)
Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
2026-04-29 17:03:21 +08:00
sang-neo03
ea056d132e feat(install): enhance binary URL resolution with environment variabl… (#690)
* feat(install): enhance binary URL resolution with environment variable support

* fix(install): defer mirror resolution into install() to surface friendly errors

resolveMirrorUrl was called at module scope, so an invalid
LARK_CLI_DOWNLOAD_HOST (e.g. file://) threw before the try/catch in the
postinstall entrypoint, dumping a raw stack trace instead of the recovery
guidance with proxy/registry/host-override options.

Move resolution into install() via getMirrorUrl() so the throw is caught
and the user sees the actionable help text.

* fix(install): keep npmmirror fallback when npm_config_registry is set

resolveMirrorUrl returned a single URL, so any non-default
npm_config_registry replaced the npmmirror fallback entirely. Corporate
npm proxies (Verdaccio, Artifactory, Nexus) often only serve npm package
metadata and don't host /-/binary/<pkg>/..., turning previously-working
installs into 404s when GitHub is unreachable.

Switch to resolveMirrorUrls returning an ordered chain:
  - LARK_CLI_DOWNLOAD_HOST set → [override] only (explicit user choice;
    no silent leak to npmmirror).
  - Otherwise → [derived_from_registry?, npmmirror_default]; npmmirror
    is always the final entry, restoring the pre-PR safety net.

install() now walks [GITHUB_URL, ...mirrorUrls] and stops at the first
success.

* fix(install): skip GitHub when LARK_CLI_DOWNLOAD_HOST is set

The download loop unconditionally tried GITHUB_URL first, even when the
user explicitly named a download host. In locked-down networks, probing
github.com can trigger DLP / firewall alerts and contradicts the
explicit-override semantics ("use only this host, nothing else").

When LARK_CLI_DOWNLOAD_HOST is set, the chain is now just [override].
When it isn't, behavior is unchanged: [GITHUB_URL, derived?, npmmirror].

* refactor(install): drop LARK_CLI_DOWNLOAD_HOST env override

Issue #640 only asked for --registry to influence the binary download.
The LARK_CLI_DOWNLOAD_HOST escape hatch was added speculatively for
locked-down networks but is YAGNI — users in those environments already
have npm-level mirrors (--registry) or proxy controls (https_proxy).

Removing it shrinks the surface area:
  - delete parseDownloadBase() and its strict https-only validation
  - drop the install() branch that skipped GitHub on explicit override
  - simplify failure-help message to two recovery options

Resolution chain becomes [GITHUB, derived_from_npm_config_registry?,
npmmirror_default]. The npmmirror tail still preserves the pre-PR safety
net when a corp registry doesn't actually serve /-/binary/<pkg>/...

End-to-end verified on Linux + Windows via real `npm install -g <tgz>`:
all four user scenarios pass, with the issue #640 path (--registry=
npmmirror + GitHub blocked) finishing in 2s on Linux / 6s on Windows.
2026-04-29 16:46:30 +08:00
kongenpei
7fc963f455 docs: clarify base search routing (#708)
* docs: clarify base search routing

* docs: refine base search guidance

* docs: clarify complex base search cases

* docs: define complex base search

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-29 16:21:34 +08:00
ethan-zhx
520acb618c feat(slides):slides template (#684)
* feat(slides):slides template

chore:add scripts

feat(slides): add template-first guidance to lark-slides skill

docs: restructure slide templates to flat layout with catalog routing

- Move 42 template XMLs from 8 category subdirs into single templates/ dir
- Encode category in filename: {category}--{name}.xml
- Add template-catalog.md as lightweight routing index (scene/tone/formality)
- Update SKILL.md workflow to include template matching step (Step 2)
- Update style guide to reference templates instead of hardcoded colors

docs: add categorized slides template XML references

Add 42 slide templates extracted from API responses, organized by category:
office(8), product(6), operations(4), marketing(8), hr(3), administration(4), personal(6), misc(3)

Change-Id: Ib3d85ffd7563a1693d4ed603fe9435fd716890ca

* refactor: optimize lark slides template

Change-Id: I40ab98d3882095262cc533bcb9baf614cff9adfa

---------

Co-authored-by: caichengjie.viper <caichengjie.viper@bytedance.com>
2026-04-29 16:00:03 +08:00
chanthuang
dce2beb91c feat(mail): support calendar events in emails (#646)
* feat(ics): add RFC 5545 iCalendar generator and parser

Add shortcuts/mail/ics package:
- builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER,
  ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT
- parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft
- Handles CN quoting, control-char sanitization, email validation,
  line folding per RFC 5545, and TZID edge cases

Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1

* feat(mail): support calendar events in emails

- Add --event-summary/start/end/location flags to +send, +reply,
  +reply-all, +forward, +draft-create
- Build ICS and embed as text/calendar in multipart/alternative
- Validate event time range and enforce --event/--send-time mutual
  exclusion (extracted into validateEventSendTimeExclusion)
- CalendarBody() in emlbuilder places ICS correctly
- Exclude BCC from ATTENDEE list

Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744

* feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard

- ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client
  recognizes CLI-created calendar events as editable
- ics.ParseEvent() detects IsLarkDraft field
- +draft-edit rejects --set-event-* on calendars without
  X-LARK-MAIL-DRAFT marker (read-only after send)
- Export FindPartByMediaType from draft package for cross-package use
- Add set_calendar/remove_calendar patch ops with full test coverage

Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd

* feat(mail): forward preserves original calendar ICS

When forwarding an email that contains a calendar event (body_calendar),
pass through the original ICS bytes as text/calendar part if no new
--event-* flags are specified.

Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57

* docs(mail): document calendar invitation feature

- Add --event-* params to +send, +reply, +reply-all, +forward,
  +draft-create, +draft-edit reference docs
- Add calendar_event output section to +message reference
- Add calendar invitation workflow to skill-template/domains/mail.md
- Regenerate SKILL.md via gen-skills

Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff

* fix(mail): reject --set-event-start/end/location without --set-event-summary

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

* fix(mail): include --event-location in validateEventFlags; fix stale comment

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

* fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative

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

* fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar

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

* fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-*

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

* fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution

Extract effectiveRecipients() to replay all three recipient op types
(set_recipients, add_recipient, remove_recipient) before building the
ICS for set_calendar, so patch-file recipient changes are reflected in
ATTENDEE metadata.

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

* fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST

Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a
Content-Type with method=REQUEST, disagreeing with the body. Now
extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST
when absent, keeping existing behavior for our own generated ICS.

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

* fix(mail): normalize calendar_event start/end to UTC in output

Callers expect RFC 3339 UTC strings; source ICS with TZID offsets
previously emitted +08:00 instead of Z.

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

* fix(mail): make ICS parser case-insensitive and handle parameterized property names

RFC 5545 §3.1 allows any case and optional parameters on all property
names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via
strings.ToUpper(name) and add HasPrefix checks for the NAME; form,
consistent with how ORGANIZER and ATTENDEE were already handled.

Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815
Co-Authored-By: AI
2026-04-29 15:31:38 +08:00
zgz2048
97968b6ef2 docs(base): align base skills and view config contracts (#653)
* docs(base): align base skills and view config contracts

1. Rework the lark-base source-of-truth docs around canonical field, cell, record and view payload shapes.

2. Refresh view, workflow, lookup and related references against current openapi behavior and remove stale or broken guidance.

3. Remove dead array-wrapper handling from view sort/group setters and add unit plus dry-run e2e coverage for object-only input.

* docs(base): drop view config code changes from doc refactor

1. Revert the temporary Base view config Go and test adjustments so this PR only keeps lark-base skill and reference updates.

2. Preserve the documentation contract changes while leaving runtime behavior unchanged from the pre-refactor implementation.

* docs(base): revert temporary view config code cleanup

1. Restore the pre-refactor Base view config Go paths and related unit tests so this PR keeps runtime behavior unchanged.

2. Leave the lark-base skill and reference updates in place as the only intended product change in this branch.

* docs(base): fix progress color typo

* docs(base): trim padding in reference docs

1. Remove obviously excessive alignment spaces from base reference examples and operator lists.

2. Shorten a few overlong separator rows in the formula guide to reduce low-value formatting noise.

3. Keep the changes scoped to four lark-base reference files without changing documented behavior.

* docs(base): clarify field description guidance

* test: isolate dry-run e2e config state

* chore: update data-query prompt

* docs(base): simplify formula filter guidance

* docs(base): drop stage field mention from data query

* revert: keep e2e changes scoped to base docs

* docs(base): clarify dashboard field type wording

* docs(base): trim number filter operators
2026-04-29 15:30:11 +08:00
Yuxuan Zhao
6bb988a655 test: align e2e yes flags with risk metadata (#701) 2026-04-28 23:06:43 +08:00
liangshuo-1
4422265d5f test(im): drop --yes from chats link e2e (not high-risk-write) (#700)
`im chats link` is registered as a regular service method (no
`risk: high-risk-write` annotation), so the framework does not register
the `--yes` flag on it. Setting `Yes: true` on the e2e Request makes
the runner append `--yes`, which cobra rejects with `unknown flag:
--yes` before the request is ever issued — the rest of the assertions
then fall through with empty stdout.

The flag was added in #633 alongside the risk-tiering rollout that
covered other workflows that genuinely flipped to high-risk-write.
For chats link the API call (creating a chat share link with a
configurable validity period) is not destructive and was never
re-classified, so the line is just leftover from that pass. Drop it
to restore the e2e green; if we ever decide to gate share-link
creation behind confirmation we can re-add it together with the
metadata flip.

Change-Id: Ieb094407a7f0fa18cd130a9d80c7146274b5ecc7
2026-04-28 22:06:13 +08:00
231 changed files with 92210 additions and 1756 deletions

View File

@@ -2,6 +2,46 @@
All notable changes to this project will be documented in this file.
## [v1.0.23] - 2026-04-30
### Features
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
- **drive**: Add `+status` shortcut for content-hash diff (#692)
- **drive**: Support `--file-name` for drive export (#685)
- **base**: Add markdown output for record reads (#726)
- **minutes**: Add media upload shortcut (#725)
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
- **cmdutil**: Support `@file` for params and data (#724)
- Add markdown shortcuts and skill docs (#704)
### Documentation
- **doc**: Guide lark-doc v2 usage (#710)
- **minutes**: Clarify minutes file-to-notes routing (#732)
## [v1.0.22] - 2026-04-29
### Features
- **task**: Add resource agent & `agent_task_step_info` (#693)
- **task**: Support app task members by id (#712)
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
- **slides**: Add slide templates with template-first skill guidance (#684)
- **mail**: Support calendar events in emails (#646)
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
### Bug Fixes
- **install**: Make Windows zip extraction resilient (#713)
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
### Documentation
- **base**: Clarify base search routing (#708)
- **base**: Align base skills and view config contracts (#653)
## [v1.0.21] - 2026-04-28
### Features
@@ -539,6 +579,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19

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 23 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, Markdown, and more, with 200+ commands and 24 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** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 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
@@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
| 📊 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 |
@@ -139,6 +140,7 @@ lark-cli auth status
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -28,6 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -140,6 +141,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -122,5 +122,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs"}
return []string{"base", "contact", "docs", "markdown"}
}

View File

@@ -269,7 +269,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
if err != nil {
return err
}

View File

@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -7,19 +7,20 @@ import (
"encoding/json"
"io"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin)
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil)
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil)
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -4,19 +4,27 @@
package cmdutil
import (
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
// - "-" → read all bytes from stdin
// - "@<path>" → read all bytes from the file at <path> via fileIO
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
// fileIO is required for "@<path>" inputs and goes through path validation
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
//
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
// by reading JSON from a file (@path) or piping via stdin (-).
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
if raw == "" {
return "", nil
}
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return s, nil
}
// escape: @@... → literal @... (no file read)
if strings.HasPrefix(raw, "@@") {
return raw[1:], nil
}
// file: @path
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(raw[1:])
if path == "" {
return "", fmt.Errorf("file path cannot be empty after @")
}
data, err := ReadInputFile(fileIO, path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("file %q is empty", path)
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return raw, nil
}
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}
f, err := fileIO.Open(path)
if err != nil {
return nil, wrapInputFileError(path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
func wrapInputFileError(path string, err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("invalid file path %q: %w", path, err)
}
return fmt.Errorf("cannot read file %q: %w", path, err)
}

View File

@@ -5,12 +5,15 @@ package cmdutil
import (
"fmt"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
_, err := ResolveInput("-", strings.NewReader(""), nil)
if err == nil {
t.Error("expected error for empty stdin")
}
@@ -44,21 +47,21 @@ type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
_, err := ResolveInput("-", errorReader{}, nil)
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
_, err := ResolveInput("-", nil, nil)
if err == nil {
t.Error("expected error for nil stdin")
}
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
got, err := ResolveInput(tt.in, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
got, err := ResolveInput("", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
func TestResolveInput_AtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@params.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
if got != `{"folder_token":"abc123"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@p.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_NotFound(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
_, err := ResolveInput("@missing.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
// Absolute paths are rejected by SafeInputPath; the error must surface
// as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@/etc/passwd", nil, fio)
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected path-validation error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
fio := &localfileio.LocalFileIO{}
_, err := ResolveInput("@", nil, fio)
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("expected empty-path error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := ResolveInput("@empty.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Errorf("expected empty-file error, got: %v", err)
}
}
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
// When fileIO is nil, @path must error rather than silently fall back.
_, err := ResolveInput("@params.json", nil, nil)
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Errorf("expected unavailable error, got: %v", err)
}
}
func TestResolveInput_DoubleAtEscape(t *testing.T) {
got, err := ResolveInput("@@literal", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@literal" {
t.Errorf("got %q, want %q", got, "@literal")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
got, err := ParseJSONMap("-", "--params", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
}
}
// Integration: @file flows through ParseJSONMap correctly.
func TestParseJSONMap_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
if got["folder_token"] != "abc123" {
t.Errorf("got %v, want folder_token=abc123", got)
}
}
func TestParseOptionalBody_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
got, err := ParseOptionalBody("POST", "-", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -25,10 +25,26 @@ type Stub struct {
Headers http.Header // optional full response headers (takes precedence over ContentType)
matched bool
// BodyFilter (optional): match only when the captured request body satisfies
// this predicate. Used to disambiguate multiple stubs that share a URL.
BodyFilter func([]byte) bool
// OnMatch (optional): runs synchronously after the stub matches but before
// the response is composed. Used in tests to inject panics or count
// in-flight goroutines.
OnMatch func(req *http.Request)
// Reusable (optional): when true, the stub stays available for further
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
CapturedBody []byte
// CapturedBodies records every captured request body when Reusable is set.
// (CapturedBody continues to record the most recent capture for back-compat.)
CapturedBodies [][]byte
}
// Registry records stubs and implements http.RoundTripper.
@@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) {
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
urlStr := req.URL.String()
// Read body once up-front so BodyFilter can inspect it without consuming
// the original reader; restore for downstream consumers afterwards.
// http.RoundTripper requires us to close the original body.
var capturedBody []byte
if req.Body != nil {
var err error
capturedBody, err = io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return nil, fmt.Errorf("httpmock: read request body: %w", err)
}
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
}
matched := r.match(req, urlStr, capturedBody)
if matched != nil {
// Restore body again in case OnMatch wants to read it.
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
if matched.OnMatch != nil {
matched.OnMatch(req)
}
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
}
// match selects the first stub whose Method/URL/BodyFilter all match the
// request, mutates its capture state, and returns it. defer-Unlock guarantees
// a panicking user-supplied BodyFilter cannot leak the mutex.
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
r.mu.Lock()
var matched *Stub
defer r.mu.Unlock()
for _, s := range r.stubs {
if s.matched {
continue
@@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
continue
}
s.matched = true
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
continue
}
if !s.Reusable {
s.matched = true
}
s.CapturedHeaders = req.Header.Clone()
if req.Body != nil {
s.CapturedBody, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
}
matched = s
break
s.CapturedBody = capturedBody
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
return s
}
r.mu.Unlock()
if matched != nil {
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
return nil
}
// Verify asserts all stubs were matched.
@@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.stubs {
if !s.matched {
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
if s.matched {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue
}
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
}
}

View File

@@ -15,6 +15,7 @@ import (
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"users",
}
// FindArrayField finds the primary array field in a response's data object.

View File

@@ -35,6 +35,10 @@
"en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" },
"zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" }
},
"markdown": {
"en": { "title": "Markdown", "description": "Drive-native Markdown file create, fetch, and overwrite" },
"zh": { "title": "Markdown", "description": "Drive 原生 Markdown 文件的创建、读取和覆盖更新" }
},
"minutes": {
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }

View File

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

View File

@@ -10,15 +10,16 @@ const crypto = require("crypto");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const REPO = "larksuite/cli";
const NAME = "lark-cli";
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
// Allowlist gates the *initial* request URL only. curl --location follows
// redirects (capped by --max-redirs 3) without re-checking the target host.
// This is acceptable because checksum verification is the primary integrity
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
const ALLOWED_HOSTS = [
const ALLOWED_HOSTS = new Set([
"github.com",
"objects.githubusercontent.com",
"registry.npmmirror.com",
];
]);
const PLATFORM_MAP = {
darwin: "darwin",
@@ -38,18 +39,77 @@ const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
// Build the ordered list of binary mirror URLs to try. Resolution rules:
// 1. npm_config_registry — when the user has set a non-default
// registry (npmmirror clone, corp Verdaccio,
// Artifactory, …), include the derived path
// first. Many of these proxies don't actually
// host /-/binary/<pkg>/..., so we ALWAYS
// append the public npmmirror as a final
// fallback so the install does not regress
// from the previous behavior of "GitHub →
// npmmirror".
// 2. registry.npmmirror.com — public China mirror, always tried last.
// The default public npmjs registry is skipped in step 1 because it does not
// host binaries under /-/binary/...
//
// Non-https / malformed npm_config_registry is silently ignored so npm users
// with http-only internal registries don't have their installs broken.
function resolveMirrorUrls(env, archive, version) {
const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`;
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
const urls = [];
const registry = (env.npm_config_registry || "").trim();
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
const base = new URL(registry);
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
}
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
return urls;
}
function joinUrl(base, suffix) {
return base.replace(/\/+$/, "") + suffix;
}
function isValidDownloadBase(raw) {
try {
const parsed = new URL(raw);
return parsed.protocol === "https:" && !!parsed.hostname;
} catch (_) {
return false;
}
}
function isDefaultNpmjsRegistry(url) {
try {
const { hostname } = new URL(url);
return hostname === "registry.npmjs.org";
} catch (_) {
return false;
}
}
function assertAllowedHost(url) {
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.includes(hostname)) {
if (!ALLOWED_HOSTS.has(hostname)) {
throw new Error(`Download host not allowed: ${hostname}`);
}
}
// Resolve the mirror URL chain and admit each host. Called from install() so
// derived hosts only become trusted when actually needed.
function getMirrorUrls(env) {
const urls = resolveMirrorUrls(env, archiveName, VERSION);
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
return urls;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
@@ -65,27 +125,69 @@ function download(url, destPath) {
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function extractZipWindows(archivePath, destDir) {
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
const psStdio = ["ignore", "inherit", "inherit"];
const psEnv = {
...process.env,
LARK_CLI_ARCHIVE: archivePath,
LARK_CLI_DEST: destDir,
};
try {
const dotnet =
"$ErrorActionPreference='Stop';" +
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
} catch (primaryErr) {
try {
const cmdlet =
"$ErrorActionPreference='Stop';" +
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${fallbackErr.message}`
);
}
}
}
function install() {
const mirrorUrls = getMirrorUrls(process.env);
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
fs.mkdirSync(binDir, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
// Walk the chain in order; stop at the first success. Default chain:
// GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror
// tail preserves the pre-PR safety net when a corporate proxy doesn't
// actually host /-/binary/<pkg>/...
let lastErr;
let downloaded = false;
for (const url of downloadUrls) {
try {
download(url, archivePath);
downloaded = true;
break;
} catch (e) {
lastErr = e;
}
}
if (!downloaded) throw lastErr;
const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);
if (isWindows) {
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
extractZipWindows(archivePath, tmpDir);
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
@@ -176,12 +278,15 @@ if (require.main === module) {
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
` # 1. Use a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
` npm install -g @larksuite/cli\n\n` +
` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` +
` npm install -g @larksuite/cli --registry=https://your-corp-mirror/`
);
process.exit(1);
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };

View File

@@ -9,7 +9,7 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
@@ -164,3 +164,117 @@ describe("assertAllowedHost", () => {
);
});
});
describe("resolveMirrorUrls", () => {
const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz";
const VERSION = "1.0.0";
const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz";
it("returns only the default mirror when no env vars are set", () => {
assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]);
});
it("does not derive from the default npmjs registry", () => {
// The public npmjs registry doesn't host /-/binary/<pkg>/..., so we must
// not point downloads at it.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmjs.org/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("derives from non-default npm_config_registry AND keeps default as fallback", () => {
// Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often
// doesn't actually serve /-/binary/<pkg>/..., so we must keep the
// public npmmirror as a final fallback or installs regress vs. the
// pre-PR "GitHub → npmmirror" behavior.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/repository/npm-public/" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("derived URL appears before the default in the chain", () => {
const urls = resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/" },
ARCHIVE,
VERSION
);
assert.equal(urls.length, 2);
assert.match(urls[0], /^https:\/\/corp\.example\.com\//);
assert.equal(urls[1], DEFAULT);
});
it("does not duplicate the default if the registry already points at it", () => {
// If npm_config_registry happens to be the public npmmirror, we still
// want a single entry, not two identical ones.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmmirror.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("strips trailing slashes from the registry URL", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com///" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("ignores empty/whitespace npm_config_registry", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is non-https", () => {
// Implicit feature: don't break installs whose npm registry is plain http.
// The user didn't opt into binary-mirror behavior, so just use the default.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "http://internal.example.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is file://", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "file:///tmp" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
});

View File

@@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
}
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("list", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"records": map[string]interface{}{
"schema": []interface{}{"Name", "Age"},
"record_ids": []interface{}{"rec_1"},
"rows": []interface{}{[]interface{}{"Alice", 18}},
}},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list with fields and view", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
@@ -887,7 +865,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
@@ -895,7 +873,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list new shape", func(t *testing.T) {
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -904,13 +882,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_2"},
"data": []interface{}{[]interface{}{"Bob", 20}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
@@ -918,6 +897,47 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{
[]interface{}{"Alice", 18},
[]interface{}{"Bob", 20},
},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "all_records",
"field_scope": "selected_fields",
},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"| _record_id | Name | Age |",
"| rec_1 | Alice | 18 |",
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
"Ignored fields: Formula",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("search", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
@@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
"--format", "json",
},
factory,
stdout,
@@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("search markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title", "Owner"},
"field_id_list": []interface{}{"fld_title", "fld_owner"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "view_filtered_records",
"field_scope": "selected_fields",
"search_scope": "fld_title(Title)",
},
},
},
})
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Title | Owner |",
"| rec_1 | Created by AI | Alice |",
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
@@ -1674,7 +1742,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
}, "total": 201},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {

View File

@@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -210,6 +210,102 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "record list",
shortcut: BaseRecordList,
wantHelp: []string{
"field ID or name to include; repeat to project only needed fields",
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
"pagination size, range 1-200",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown",
"Use --field-id repeatedly to keep output small",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
"lark-base record read SOP",
},
},
{
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
"requires keyword/search_fields",
"optional select_fields/view_id/offset/limit",
"output format: markdown (default) | json",
},
wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"search_fields length 1-20",
"limit range 1-200 defaults to 10",
"view_id scopes search to records in that view",
"Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"record_id is already known",
"lark-base record read SOP",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
assertHelpOrder(t, help, "base token", "output format")
assertHelpOrder(t, help, "table ID", "output format")
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)
afterIndex := strings.Index(help, after)
if beforeIndex < 0 || afterIndex < 0 {
return
}
if beforeIndex > afterIndex {
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
}
}
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 || !strings.Contains(err.Error(), "--json invalid JSON object") {

10
shortcuts/base/help.go Normal file
View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import "github.com/spf13/cobra"
func preserveFlagOrder(cmd *cobra.Command) {
cmd.Flags().SortFlags = false
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordGet = common.Shortcut{
@@ -21,7 +22,15 @@ var BaseRecordGet = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
},
Tips: []string{
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
"Agent hint: follow the lark-base record read SOP for record read routing.",
},
DryRun: dryRunRecordGet,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordGet(runtime)
},

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordList = common.Shortcut{
@@ -19,13 +20,46 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
{Name: "view-id", Desc: "view ID"},
recordListFieldRefFlag(),
recordListViewRefFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),
},
Tips: []string{
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id repeatedly to keep output small and aligned with the task.",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
},
DryRun: dryRunRecordList,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordList(runtime)
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
return flag
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
return flag
}
func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordMarkdownIgnoredFields = 20
func validateRecordReadFormat(runtime *common.RuntimeContext) error {
switch runtime.Str("format") {
case "", "json", "markdown":
return nil
default:
return output.ErrValidation("--format must be json or markdown")
}
}
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.Out(data, nil)
return nil
}
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
}
rendered, err := renderRecordMarkdown(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.Out(data, nil)
return nil
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
}
fmt.Fprint(runtime.IO().Out, rendered)
return nil
}
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
fields := stringSliceValue(data["fields"])
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
}
var b strings.Builder
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
columns := append([]string{"_record_id"}, fields...)
writeMarkdownRow(&b, columns)
writeMarkdownSeparator(&b, len(columns))
for i, rowValue := range rows {
rowItems, _ := rowValue.([]interface{})
cells := make([]string, 0, len(columns))
if i < len(recordIDs) {
cells = append(cells, recordIDs[i])
} else {
cells = append(cells, "")
}
for j := range fields {
if j < len(rowItems) {
cells = append(cells, markdownCell(rowItems[j]))
} else {
cells = append(cells, "")
}
}
writeMarkdownRow(&b, cells)
}
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
b.WriteString("Ignored fields: ")
b.WriteString(ignored)
b.WriteByte('\n')
}
return b.String(), nil
}
func recordMarkdownMeta(data map[string]interface{}) []string {
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
if hasMore, ok := data["has_more"]; ok {
meta = append(meta, "has_more="+markdownInlineValue(hasMore))
}
if queryContext, ok := data["query_context"].(map[string]interface{}); ok {
for _, key := range []string{"record_scope", "field_scope", "search_scope"} {
if value, ok := queryContext[key]; ok {
meta = append(meta, key+"="+markdownInlineValue(value))
}
}
}
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
}
return meta
}
func ignoredFieldsCount(value interface{}) int {
switch v := value.(type) {
case []interface{}:
return len(v)
case []string:
return len(v)
case nil:
return 0
default:
return 1
}
}
func ignoredFieldsMarkdown(value interface{}) string {
items := markdownListItems(value)
if len(items) == 0 {
return ""
}
total := len(items)
if len(items) > maxRecordMarkdownIgnoredFields {
items = items[:maxRecordMarkdownIgnoredFields]
items = append(items, fmt.Sprintf("...(%d total)", total))
}
return strings.Join(items, ", ")
}
func markdownListItems(value interface{}) []string {
switch v := value.(type) {
case []interface{}:
items := make([]string, 0, len(v))
for _, item := range v {
items = append(items, markdownInlineValue(item))
}
return items
case []string:
items := make([]string, 0, len(v))
for _, item := range v {
items = append(items, markdownInlineValue(item))
}
return items
case nil:
return nil
default:
return []string{markdownInlineValue(v)}
}
}
func writeMarkdownRow(b *strings.Builder, cells []string) {
b.WriteString("| ")
for i, cell := range cells {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString(markdownTableText(cell))
}
b.WriteString(" |\n")
}
func writeMarkdownSeparator(b *strings.Builder, columns int) {
b.WriteString("| ")
for i := 0; i < columns; i++ {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString("---")
}
b.WriteString(" |\n")
}
func markdownCell(value interface{}) string {
return markdownInlineValue(value)
}
func markdownInlineValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
case json.Number:
return v.String()
case bool:
if v {
return "true"
}
return "false"
case float64:
return fmt.Sprintf("%v", v)
case int:
return fmt.Sprintf("%d", v)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
func markdownTableText(value string) string {
value = strings.ReplaceAll(value, "\\", "\\\\")
value = strings.ReplaceAll(value, "|", "\\|")
value = strings.ReplaceAll(value, "\r\n", "<br>")
value = strings.ReplaceAll(value, "\n", "<br>")
return value
}
func stringSliceValue(value interface{}) []string {
switch v := value.(type) {
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case []string:
return append([]string(nil), v...)
default:
return nil
}
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type recordMarkdownCSTestProvider struct {
alert *extcs.Alert
}
func (p *recordMarkdownCSTestProvider) Name() string { return "test" }
func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
parentCmd := &cobra.Command{Use: "lark-cli"}
baseCmd := &cobra.Command{Use: "base"}
cmd := &cobra.Command{Use: "+record-list"}
cmd.Flags().String("format", "markdown", "")
parentCmd.AddCommand(baseCmd)
baseCmd.AddCommand(cmd)
return &common.RuntimeContext{
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
}
}
func TestRenderRecordMarkdownEmptyResult(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{},
"data": []interface{}{},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"| _record_id | Name | Age |",
"Meta: count=0; has_more=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name|Label", "Note"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"| _record_id | Name\\|Label | Note |",
"| rec_1 | A\\|B | line1<br>line2 |",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
for i := range ignored {
ignored[i] = fmt.Sprintf("Field%d", i+1)
}
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
"ignored_fields": ignored,
})
if err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(got, fmt.Sprintf("ignored_fields=%d", len(ignored))) ||
!strings.Contains(got, fmt.Sprintf("...(%d total)", len(ignored))) ||
strings.Contains(got, "Field22") {
t.Fatalf("ignored field truncation mismatch:\n%s", got)
}
}
func TestOutputRecordMarkdownContentSafetyWarnKeepsStdoutClean(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&recordMarkdownCSTestProvider{
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
})
defer extcs.Register(nil)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "| rec_1 | Alice |") || strings.Contains(got, "content safety") {
t.Fatalf("stdout should contain only markdown data, got:\n%s", got)
}
if got := stderr.String(); !strings.Contains(got, "warning: content safety alert") {
t.Fatalf("stderr missing content safety warning:\n%s", got)
}
}
func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&recordMarkdownCSTestProvider{
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
})
defer extcs.Register(nil)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
t.Fatalf("err=%v, want content safety exit error", err)
}
if stdout.Len() > 0 {
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
}
if stderr.Len() > 0 {
t.Fatalf("block mode should not write warning to stderr, got:\n%s", stderr.String())
}
}
func TestOutputRecordMarkdownFallsBackToJSONWhenRenderFails(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"records": map[string]interface{}{
"schema": []interface{}{"Name"},
"rows": []interface{}{[]interface{}{"Alice"}},
},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if strings.Contains(stdout.String(), "markdown render failed") {
t.Fatalf("stdout should not contain fallback warning:\n%s", stdout.String())
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("stdout should be JSON fallback, got err=%v stdout=%s", err, stdout.String())
}
if !env.OK || !strings.Contains(stdout.String(), `"records"`) {
t.Fatalf("stdout missing JSON fallback data:\n%s", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "warning: record markdown render failed, falling back to json") {
t.Fatalf("stderr missing fallback warning:\n%s", got)
}
}
func TestOutputRecordMarkdownDefaultFormatAllowsJqJSONFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
runtime.JqExpr = ".data.record_id_list[0]"
err := outputRecordMarkdown(runtime, map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "rec_1" {
t.Fatalf("stdout jq fallback mismatch: %q", got)
}
if stderr.Len() > 0 {
t.Fatalf("stderr should be empty, got:\n%s", stderr.String())
}
}
func TestOutputRecordMarkdownExplicitFormatRejectsJq(t *testing.T) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
runtime.JqExpr = ".data"
if err := runtime.Cmd.Flags().Set("format", "markdown"); err != nil {
t.Fatalf("set format: %v", err)
}
err := outputRecordMarkdown(runtime, map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err == nil || !strings.Contains(err.Error(), "--jq and --format markdown are mutually exclusive") {
t.Fatalf("err=%v, want jq markdown conflict", err)
}
if stdout.Len() > 0 {
t.Fatalf("stdout should be empty, got:\n%s", stdout.String())
}
}

View File

@@ -173,6 +173,9 @@ func recordListFields(runtime *common.RuntimeContext) []string {
}
func executeRecordList(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
@@ -190,6 +193,9 @@ func executeRecordList(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}
@@ -213,6 +219,9 @@ func executeRecordSearch(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordSearch = common.Shortcut{
@@ -19,16 +20,28 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "record search JSON object", Required: true},
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
recordReadFormatFlag(),
},
Tips: []string{
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordSearch,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordSearch(runtime)
},

View File

@@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client {
return ctx.larkSDK
}
// EnsureScopes runs the same pre-flight scope check used by the framework
// before Validate, but on a caller-supplied set of scopes. Use it from a
// shortcut's Validate to enforce conditional scope requirements that depend
// on flag values (e.g. --delete-remote needing space:document:delete) so a
// destructive operation never starts on a token that can't finish it.
//
// Behavior matches checkShortcutScopes: when no token is available or the
// resolver doesn't expose scope metadata, this is a silent no-op — the
// downstream API call still surfaces missing_scope at runtime.
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
}
// ── Flag accessors ──
// Str returns a string flag value.
@@ -882,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
f, err := rctx.FileIO().Open(path)
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
}
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue

View File

@@ -5,7 +5,6 @@ package common
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
func TestResolveInputFlags_File(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
content := "## Hello\n\nThis is **markdown** from a file.\n"
fpath := filepath.Join(dir, "test.md")
os.WriteFile(fpath, []byte(content), 0644)
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
}
}
func TestResolveInputFlags_EmptyFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestResolveInputFlags_EmptyInput(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
func TestResolveInputFlags_FileNotFound(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
if !strings.Contains(err.Error(), "file path cannot be empty") {
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -123,7 +123,7 @@ type searchUser struct {
P2PChatID string `json:"p2p_chat_id"`
HasChatted bool `json:"has_chatted"`
Department string `json:"department"`
Signature string `json:"signature"`
Signature string `json:"signature,omitempty"`
ChatRecencyHint string `json:"chat_recency_hint"`
MatchSegments []string `json:"match_segments"`
}
@@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{
{Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"},
{Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice' --format json",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json",
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
queries := parseAndDedupQueries(raw)
filter, err := buildFanoutFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range queries {
body := &searchUserAPIRequest{Query: q}
if filter != nil {
body.Filter = filter
}
api.POST(searchUserURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildSearchUserBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{
}
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeSearchUserFanout(ctx, runtime)
}
return executeSearchUserSingle(ctx, runtime)
}
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildSearchUserBody(runtime)
if err != nil {
return err
@@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
func validateSearchUser(runtime *common.RuntimeContext) error {
if !hasAnySearchInput(runtime) {
return common.FlagErrorf(
"specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
)
}
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
if queriesRaw != "" {
if strings.TrimSpace(runtime.Str("query")) != "" {
return common.FlagErrorf("--query and --queries are mutually exclusive")
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
}
if len(queries) > maxFanoutQueries {
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
}
}
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
@@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("query")) != "" {
return true
}
if strings.TrimSpace(runtime.Str("queries")) != "" {
return true
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return true
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
maxFanoutQueries = 20
fanoutConcurrency = 5
)
// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty
// entries, and deduplicates case-sensitively while preserving first-occurrence
// order.
func parseAndDedupQueries(raw string) []string {
parts := common.SplitCSV(raw)
seen := make(map[string]bool, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return out
}
type fanoutResult struct {
Index int
Query string
Users []searchUser
HasMore bool
ErrMsg string // empty = success
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
// because that summary lives on stderr and never corrupts the csv stream on
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
// for its refine hint, so adding csv here doesn't regress that path.
func isFanoutSummaryFormat(format string) bool {
return format == "pretty" || format == "table" || format == "csv"
}
// runOneQuery converts every failure mode (transport, HTTP status, parse,
// API code) into an ErrMsg string instead of returning a Go error. The
// fanout dispatcher (Task 6) relies on this so a single failed query never
// short-circuits the remaining workers.
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *searchUserAPIFilter) fanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a
// request; in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
body := &searchUserAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: searchUserURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
if apiResp.StatusCode != http.StatusOK {
body := strings.TrimSpace(string(apiResp.RawBody))
const maxBody = 200
if len(body) > maxBody {
body = body[:maxBody] + "..."
}
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
if body != "" {
msg = fmt.Sprintf("%s: %s", msg, body)
}
return fanoutResult{Index: index, Query: query,
ErrMsg: msg,
ErrCode: apiResp.StatusCode}
}
var resp searchUserAPIEnvelope
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
}
if resp.Code != 0 {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
ErrCode: resp.Code}
}
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
}
type fanoutUser struct {
searchUser
MatchedQuery string `json:"matched_query"`
}
type querySummary struct {
Query string `json:"query"`
Error string `json:"error,omitempty"`
HasMore bool `json:"has_more"`
}
type fanoutResponse struct {
Users []fanoutUser `json:"users"`
Queries []querySummary `json:"queries"`
}
// buildFanoutResponse walks results by Index (input order), flattens users[]
// with matched_query, lists every input in queries[] (including successes),
// and returns an error only when every query failed. The error wraps the
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
indexed := make([]fanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &fanoutResponse{
Users: make([]fanoutUser, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErrCode int
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErrCode = r.ErrCode
}
continue
}
for _, u := range r.Users {
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
// means success in the Lark protocol, so don't pretend it's an API error
// when we have nothing structured to report.
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
}
return out, nil
}
func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildFanoutFilter(runtime)
if err != nil {
return err
}
results := make([]fanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
for i, q := range queries {
wg.Add(1)
sem <- struct{}{}
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
results[i] = fanoutResult{
Index: i,
Query: q,
ErrMsg: fmt.Sprintf("internal error: %v", r),
}
}
}()
results[i] = runOneQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Users)}, func(w io.Writer) {
if len(resp.Users) == 0 {
fmt.Fprintln(w, "No users found.")
return
}
output.PrintTable(w, prettyFanoutUserRows(resp.Users))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n",
len(queries), len(resp.Users), failed, hasMoreCount)
}
return nil
}
func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) {
filter := &searchUserAPIFilter{}
hasFilter := false
for _, bf := range searchUserBoolFilters {
if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) {
bf.Apply(filter)
hasFilter = true
}
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
rows = append(rows, map[string]interface{}{
"matched_query": u.MatchedQuery,
"localized_name": u.LocalizedName,
"department": common.TruncateStr(u.Department, 50),
"enterprise_email": u.EnterpriseEmail,
"has_chatted": u.HasChatted,
"chat_recency_hint": u.ChatRecencyHint,
"open_id": u.OpenID,
})
}
return rows
}

View File

@@ -5,10 +5,14 @@ package contact
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/larksuite/cli/internal/cmdutil"
@@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
}
}
// Most users have no signature; the field is omitempty so an empty value
// must not appear at all in the JSON, not as "" — agents shouldn't have to
// distinguish "absent" from "empty string".
func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
"meta_data": map[string]interface{}{
"i18n_names": map[string]interface{}{"zh_cn": "无签名用户"},
"mail_address": "x@example.com",
"description": "",
},
},
},
"has_more": false,
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
users := got["data"].(map[string]interface{})["users"].([]interface{})
u := users[0].(map[string]interface{})
if _, present := u["signature"]; present {
t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"])
}
}
func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
@@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
reg.Verify(t)
}
func newSearchUserTestCommandWithQueries() *cobra.Command {
cmd := newSearchUserTestCommand()
cmd.Flags().String("queries", "", "")
return cmd
}
func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("query", "alice")
_ = cmd.Flags().Set("queries", "bob,carol")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("user-ids", "ou_a")
_ = cmd.Flags().Set("queries", "bob")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_AllSeparators_Errors(t *testing.T) {
for _, raw := range []string{",,,", " , , ", ","} {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("queries", raw)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "no valid query parsed") {
t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err)
}
}
}
func TestValidateQueries_OverLength_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
long := strings.Repeat("a", 51)
_ = cmd.Flags().Set("queries", "short,"+long)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") {
t.Fatalf("expected length error mentioning 50, got %v", err)
}
}
func TestValidateQueries_Over20_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
parts := make([]string, 21)
for i := range parts {
parts[i] = fmt.Sprintf("q%02d", i)
}
_ = cmd.Flags().Set("queries", strings.Join(parts, ","))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") {
t.Fatalf("expected 20-cap error, got %v", err)
}
}
func TestParseQueries_TrimAndSkipEmpty(t *testing.T) {
got := parseAndDedupQueries("a, ,b ,")
want := []string{"a", "b"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("parseAndDedupQueries: got %v, want %v", got, want)
}
}
func TestParseQueries_DedupCaseSensitive(t *testing.T) {
got := parseAndDedupQueries("alice,Alice,alice")
want := []string{"alice", "Alice"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want)
}
}
func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data, _ := got["data"].(map[string]interface{})
if _, hasQueries := data["queries"]; hasQueries {
t.Errorf("single-query mode must NOT emit data.queries; got=%v", data)
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users len = %d, want 1", len(users))
}
u, _ := users[0].(map[string]interface{})
if _, hasMatched := u["matched_query"]; hasMatched {
t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u)
}
if _, hasTopHasMore := data["has_more"]; !hasTopHasMore {
t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data)
}
}
// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test
// command's flag set, so runOneQuery can be exercised directly without going
// through the cobra dispatcher. Mirrors what mountAndRun would build, minus
// the parent-command plumbing the worker doesn't need.
func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
cmd := newSearchUserTestCommand()
rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser)
return rt, reg
}
func TestRunOneQuery_Success(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(searchUserStub())
got := runOneQuery(context.Background(), rt, 0, "张三", nil)
if got.ErrMsg != "" {
t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg)
}
if got.Index != 0 || got.Query != "张三" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" {
t.Errorf("Users mismatch: %+v", got.Users)
}
if got.HasMore {
t.Errorf("HasMore should be false")
}
}
func TestRunOneQuery_APINonZeroCode(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"},
})
got := runOneQuery(context.Background(), rt, 3, "alice", nil)
if got.Index != 3 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.ErrMsg != "API 99991663: rate limited" {
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestRunOneQuery_HTTPNon200(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 503,
Body: map[string]interface{}{"reason": "upstream_unavailable"},
})
got := runOneQuery(context.Background(), rt, 1, "bob", nil)
if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") {
t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg)
}
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
}
if got.ErrCode != 503 {
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
}
}
func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
long := strings.Repeat("x", 1000)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 500,
Body: map[string]interface{}{"detail": long},
})
got := runOneQuery(context.Background(), rt, 0, "alice", nil)
if !strings.HasSuffix(got.ErrMsg, "...") {
t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg)
}
if len(got.ErrMsg) > 300 {
t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg)
}
}
// SDK-level transport / envelope-unmarshal failures arrive as Go errors from
// runtime.DoAPI; the worker converts them by calling err.Error() rather than
// adding its own prefix, so the assertion here is "ErrMsg is non-empty and
// preserves the underlying message" — the exact text comes from the SDK.
func TestRunOneQuery_TransportError(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
RawBody: []byte("{not-json"),
})
got := runOneQuery(context.Background(), rt, 2, "carol", nil)
if got.ErrMsg == "" {
t.Fatalf("expected non-empty ErrMsg for malformed body")
}
if got.Index != 2 || got.Query != "carol" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
results := []fanoutResult{
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false},
{Index: 2, Query: "carol", ErrMsg: "API 1: nope"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Users) != 3 {
t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users))
}
if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" {
t.Errorf("Users[0]: got %+v", resp.Users[0])
}
if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" {
t.Errorf("Users[1]: got %+v", resp.Users[1])
}
if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" {
t.Errorf("Users[2]: got %+v", resp.Users[2])
}
if len(resp.Queries) != 3 {
t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries))
}
want := []querySummary{
{Query: "alice", Error: "", HasMore: false},
{Query: "bob", Error: "", HasMore: true},
{Query: "carol", Error: "API 1: nope", HasMore: false},
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("expected first error (rate limit) to be returned; got %v", err)
}
// Document the count is part of the message — agents grep for it.
if !strings.Contains(err.Error(), "all 2 queries failed") {
t.Errorf("expected 'all 2 queries failed' substring; got %v", err)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("error should contain first ErrMsg; got %v", err)
}
}
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}},
{Index: 1, Query: "bob", ErrMsg: "API 5: not found"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err != nil {
t.Fatalf("partial failure must NOT be a hard error; got %v", err)
}
if len(resp.Users) != 1 {
t.Errorf("Users: got %d, want 1", len(resp.Users))
}
if resp.Queries[1].Error != "API 5: not found" {
t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error)
}
}
func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", HasMore: true},
}
resp, err := buildFanoutResponse([]string{"alice"}, results)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
raw, _ := json.Marshal(resp)
var asMap map[string]interface{}
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := asMap["has_more"]; ok {
t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap)
}
if _, ok := asMap["users"]; !ok {
t.Errorf("fanoutResponse missing users")
}
if _, ok := asMap["queries"]; !ok {
t.Errorf("fanoutResponse missing queries")
}
}
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
// are no longer accepted. cobra must reject the unknown flag at parse time —
// no stub is registered because the command should never reach the API.
@@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
})
}
}
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
}
reg.Register(stub)
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) < 2 {
t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies))
}
bodyByQuery := map[string]map[string]interface{}{}
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
bodyByQuery[body["query"].(string)] = body
filter, _ := body["filter"].(map[string]interface{})
if filter == nil || filter["has_contact"] != true {
t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body)
}
}
if _, ok := bodyByQuery["alice"]; !ok {
t.Errorf("missing request for query=alice; captured=%v", bodyByQuery)
}
if _, ok := bodyByQuery["bob"]; !ok {
t.Errorf("missing request for query=bob; captured=%v", bodyByQuery)
}
}
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) },
Status: 500,
Body: map[string]interface{}{},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial failure should NOT propagate as error; got %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
users := data["users"].([]interface{})
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])
}
}
func TestFanout_AllFailed_ExitNonZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Status: 500, Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
// First failure's HTTP code (500) and a digestible reason must propagate
// so agents can classify (vs. a generic ExitInternal masking the upstream).
msg := err.Error()
if !strings.Contains(msg, "500") {
t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg)
}
if !strings.Contains(msg, "all 2 queries failed") {
t.Errorf("error must indicate the all-failed mode; got %q", msg)
}
}
func TestFanout_ConcurrencyLimitFive(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
var inFlight, peak int32
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
OnMatch: func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
},
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", strings.Join(queries, ","),
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > 5 {
t.Errorf("concurrency peak = %d, want ≤ 5", peak)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak)
}
}
func TestFanout_PanicRecovery(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) },
OnMatch: func(req *http.Request) {
panic("synthetic test panic")
},
Body: map[string]interface{}{},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial panic must not bubble; got %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
queries := got["data"].(map[string]interface{})["queries"].([]interface{})
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "internal error:") {
t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"])
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String())
}
}
}
func TestFanout_MatchedQueryFidelity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_x"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
users := got["data"].(map[string]interface{})["users"].([]interface{})
if len(users) != 2 {
t.Fatalf("users: got %d, want 2", len(users))
}
want := []string{"张三", "Alice 王"}
for i, w := range want {
mq := users[i].(map[string]interface{})["matched_query"]
if mq != w {
t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w)
}
}
}
func TestFanout_NDJSONStdoutClean(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, marker := range []string{"queries,", "total users", "with has_more"} {
if strings.Contains(stdout.String(), marker) {
t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String())
}
}
_ = stderr
}
func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") {
t.Errorf("csv summary should land on stderr; got=%q", stderr.String())
}
}
func TestFanout_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q; got=%q", want, out)
}
}
// One DryRunAPI description per query.
if strings.Count(out, "/contact/v3/users/search") < 2 {
t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out)
}
}
// Spec §7 promises single-query --query mode is "零变化". The fanout summary
// hint was broadened to csv (good — stderr can carry it without corrupting
// the csv stream on stdout); the single-query refine hint must NOT inherit
// that broadening, since pre-fanout it only fired on pretty/table.
func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": true,
"page_token": "tok_next",
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stderr.String(), "refine") {
t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String())
}
}
// A pre-canceled ctx must be observed by runOneQuery before it dispatches the
// HTTP call. The error string is exactly "context canceled" because that's
// what context.Context.Err().Error() returns — agents may grep for it.
func TestRunOneQuery_CtxCanceledEarly(t *testing.T) {
rt, _ := runOneQueryRuntime(t)
// Deliberately register no stub: runOneQuery must short-circuit before
// touching the transport, so the absence of a stub is the assertion.
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := runOneQuery(ctx, rt, 0, "alice", nil)
if got.ErrMsg != "context canceled" {
t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled")
}
if got.Index != 0 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
}

View File

@@ -43,6 +43,7 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
@@ -110,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
@@ -122,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
md := runtime.Str("markdown")
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
"markdown": md,
}
if v := runtime.Str("title"); v != "" {
args["title"] = v

View File

@@ -49,6 +49,7 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},

View File

@@ -22,7 +22,7 @@ func v2FetchFlags() []common.Flag {
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},

View File

@@ -64,6 +64,7 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
@@ -159,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)

View File

@@ -4,6 +4,8 @@
package doc
import (
"fmt"
"io"
"regexp"
"strings"
"unicode"
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
return setextRe.ReplaceAllString(md, "$1\n\n$2")
}
// calloutTypeColors maps the semantic type= shorthand to a recommended
// [background-color, border-color] pair for Feishu callout blocks.
// Used only for hint messages — the Markdown itself is never rewritten.
var calloutTypeColors = map[string][2]string{
"warning": {"light-yellow", "yellow"},
"caution": {"light-orange", "orange"},
"note": {"light-blue", "blue"},
"info": {"light-blue", "blue"},
"tip": {"light-green", "green"},
"success": {"light-green", "green"},
"check": {"light-green", "green"},
"error": {"light-red", "red"},
"danger": {"light-red", "red"},
"important": {"light-purple", "purple"},
}
// calloutOpenTagRe matches a <callout …> opening tag.
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
// calloutTypeAttrRe extracts the value of a type= attribute (single or
// double quoted) from a callout opening tag's attribute string. The
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
// word/non-word boundary, and `-` is a non-word character, so
// `\btype=` would also match the suffix of `data-type=` and yield a
// bogus type lookup. Anchoring on start-of-string-or-whitespace
// requires a real attribute separator before the name.
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
// calloutBackgroundColorAttrRe matches a background-color= attribute
// name with optional whitespace around the equals sign, so forms like
// `background-color="..."` and `background-color = "..."` are both
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
// reason: `data-background-color="..."` must not look like a present
// background-color and silently suppress the hint.
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
// WarnCalloutType scans md for callout tags that carry a type= attribute but
// no background-color= attribute, then writes a hint line to w for each one
// suggesting the explicit Feishu color attributes to use instead.
//
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
// are documentation samples, not real callouts the user wants Feishu to
// render. Fence detection uses the shared codeFenceOpenMarker /
// isCodeFenceClose helpers so both backtick and tilde fences are handled
// (matching CommonMark §4.5).
//
// The Markdown is not modified — the caller is responsible for acting on
// the hints or ignoring them. This keeps the create/update path
// transparent: user input reaches create-doc exactly as written.
func WarnCalloutType(md string, w io.Writer) {
fenceMarker := ""
for _, line := range strings.Split(md, "\n") {
if fenceMarker != "" {
// Inside a fenced block — skip everything until the matching
// closer. Code samples that show literal <callout type=...>
// must not produce a phantom hint.
if isCodeFenceClose(line, fenceMarker) {
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
fenceMarker = marker
continue
}
scanCalloutTagsForWarning(line, w)
}
}
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
// tag in s that lacks an explicit background-color= attribute. Pulled out
// of WarnCalloutType so the line walker only handles fence state and the
// per-tag scan is its own readable unit.
//
// The previous implementation routed the tag iteration through
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
// returned the original tag and threw the rebuilt string away — using a
// rewrite primitive purely for its iteration side-effect, plus a second
// regex execution to recover the capture groups inside the callback.
// FindAllStringSubmatch hands us both the iteration and the groups in one
// pass, no allocation thrown away.
func scanCalloutTagsForWarning(s string, w io.Writer) {
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
attrs := m[1]
// Skip tags that already carry an explicit background-color.
if calloutBackgroundColorAttrRe.MatchString(attrs) {
continue
}
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
if len(parts) < 3 {
continue // no type= attribute
}
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
typeName := parts[1]
if typeName == "" {
typeName = parts[2]
}
colors, ok := calloutTypeColors[typeName]
if !ok {
continue // unknown type — no hint to give
}
fmt.Fprintf(w,
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
typeName, colors[0], colors[1])
}
}
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
// Unicode emoji characters that create-doc accepts.
var calloutEmojiAliases = map[string]string{

View File

@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
}
}
func TestWarnCalloutType(t *testing.T) {
tests := []struct {
name string
input string
wantHint bool // whether a hint line is expected
hintContains string // substring the hint must contain
}{
{
name: "warning type without background-color emits hint",
input: `<callout type="warning" emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "info type without background-color emits hint",
input: `<callout type="info" emoji="">`,
wantHint: true,
hintContains: `background-color="light-blue"`,
},
{
name: "single-quoted type attribute emits hint",
input: `<callout type='warning' emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "explicit background-color suppresses hint",
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
wantHint: false,
},
{
name: "whitespace around equals is tolerated in background-color",
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
wantHint: false,
},
{
name: "unknown type emits no hint",
input: `<callout type="custom" emoji="🔥">`,
wantHint: false,
},
{
name: "no type attribute emits no hint",
input: `<callout emoji="💡" background-color="light-green">`,
wantHint: false,
},
{
name: "non-callout tag emits no hint",
input: `<div type="warning">`,
wantHint: false,
},
{
name: "hint includes border-color suggestion",
input: `<callout type="error" emoji="❌">`,
wantHint: true,
hintContains: `border-color="red"`,
},
{
// Regression: the old `\btype=` regex matched the suffix of
// `data-type=` because `-` is a non-word character, so a tag
// carrying only data-attrs would silently get a bogus hint.
// The (?:^|\s) anchor requires a real attribute separator.
name: "data-type attribute does not trigger hint",
input: `<callout data-type="warning" emoji="📝">`,
wantHint: false,
},
{
// Symmetric guard for the background-color regex: a future
// `data-background-color=` attribute must not be mistaken
// for a present background-color and silently suppress the
// hint that the real type= would otherwise produce.
name: "data-background-color does not suppress hint",
input: `<callout type="warning" data-background-color="anything">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
// Regression for the code-fence skip: a documentation sample
// inside a ``` fence is NOT a real callout the user wants
// rendered, so it must produce no stderr noise.
name: "callout inside backtick fence emits no hint",
input: "```markdown\n" +
`<callout type="warning" emoji="📝">` + "\n" +
"```\n",
wantHint: false,
},
{
// Same skip works for tilde fences (CommonMark §4.5 makes
// `~~~` an equivalent fence character).
name: "callout inside tilde fence emits no hint",
input: "~~~markdown\n" +
`<callout type="info" emoji="">` + "\n" +
"~~~\n",
wantHint: false,
},
{
// Closing the fence must restore normal scanning: a real
// callout that follows a documentation block still gets a
// hint. Pins that fenceMarker is reset, not stuck.
name: "callout after fence close still emits hint",
input: "```markdown\n" +
`<callout type="warning">sample</callout>` + "\n" +
"```\n" +
`<callout type="error" emoji="❌">real</callout>` + "\n",
wantHint: true,
hintContains: `border-color="red"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
WarnCalloutType(tt.input, &buf)
got := buf.String()
if tt.wantHint {
if got == "" {
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
return
}
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
}
} else {
if got != "" {
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
}
}
})
}
}
func TestFixCalloutEmoji(t *testing.T) {
tests := []struct {
name string

View File

@@ -3,7 +3,24 @@
package doc
import "github.com/larksuite/cli/shortcuts/common"
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const docsServiceHelpDefault = `Document and content operations.`
const docsServiceHelpV2 = `Document and content operations (v2).`
var docsVersionSelectionTips = []string{
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
}
// Shortcuts returns all docs shortcuts.
func Shortcuts() []common.Shortcut {
@@ -18,3 +35,48 @@ func Shortcuts() []common.Shortcut {
DocMediaDownload,
}
}
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
// The shortcut-level help remains compatible with legacy v1 skills; this parent
// help gives agents enough context to choose v2 only when their installed skill
// explicitly asks for `--api-version v2`.
func ConfigureServiceHelp(cmd *cobra.Command) {
if cmd == nil {
return
}
serviceCmd := cmd
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
if cmd.Flags().Lookup("api-version") == nil {
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
})
}
defaultHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != serviceCmd {
defaultHelp(cmd, args)
return
}
apiVersion, _ := cmd.Flags().GetString("api-version")
previousLong := cmd.Long
if apiVersion == "v2" {
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
} else {
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
}
defer func() {
cmd.Long = previousLong
}()
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range docsVersionSelectionTips {
fmt.Fprintf(out, " • %s\n", tip)
}
})
}

View File

@@ -30,13 +30,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n",
cmd.Parent().Name(), cmd.Name())
}
})
}

View File

@@ -33,6 +33,7 @@ var DriveExport = common.Shortcut{
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
@@ -54,14 +55,19 @@ var DriveExport = common.Shortcut{
// Markdown export is a special case: docx markdown comes from docs content
// directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
GET("/open-apis/docs/v1/content").
Params(map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
})
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
body := map[string]interface{}{
@@ -73,10 +79,15 @@ var DriveExport = common.Shortcut{
body["sub_id"] = spec.SubID
}
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body)
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
@@ -86,6 +97,7 @@ var DriveExport = common.Shortcut{
SubID: runtime.Str("sub-id"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
@@ -106,14 +118,18 @@ var DriveExport = common.Shortcut{
return err
}
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
if err != nil {
return err
@@ -166,7 +182,11 @@ var DriveExport = common.Shortcut{
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
@@ -227,7 +247,7 @@ var DriveExport = common.Shortcut{
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
runtime.Out(map[string]interface{}{
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
@@ -238,7 +258,11 @@ var DriveExport = common.Shortcut{
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}, nil)
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},

View File

@@ -130,6 +130,155 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
}
}
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# custom\n",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "custom-notes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# custom\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "custom-notes.md"`) {
t.Fatalf("stdout missing provided file name: %s", stdout.String())
}
}
func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
tests := []struct {
name string
wantURL string
wantFileName string
args []string
}{
{
name: "markdown",
wantURL: "/open-apis/docs/v1/content",
wantFileName: `"file_name": "notes.md"`,
args: []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "notes",
"--output-dir", "./exports",
"--dry-run",
"--as", "bot",
},
},
{
name: "async export",
wantURL: "/open-apis/drive/v1/export_tasks",
wantFileName: `"file_name": "report.pdf"`,
args: []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "report",
"--output-dir", "./exports",
"--dry-run",
"--as", "bot",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveExport, tt.args, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, tt.wantURL) {
t.Fatalf("stdout missing URL %q: %s", tt.wantURL, out)
}
if !strings.Contains(out, tt.wantFileName) {
t.Fatalf("stdout missing file_name metadata %q: %s", tt.wantFileName, out)
}
if !strings.Contains(out, `"output_dir": "./exports"`) {
t.Fatalf("stdout missing output_dir metadata: %s", out)
}
})
}
}
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# fallback\n",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Status: 500,
Body: map[string]interface{}{
"code": 999,
"msg": "metadata unavailable",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# fallback\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "docx123.md"`) {
t.Fatalf("stdout missing fallback file name: %s", stdout.String())
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -200,6 +349,77 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_custom"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_custom",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_custom",
"file_name": "server-name",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_custom/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="server-name.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "custom-report",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-report.pdf"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "custom-report.pdf"`) {
t.Fatalf("stdout missing provided file name: %s", stdout.String())
}
}
func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
@@ -425,6 +645,51 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}
func TestDriveExportTimeoutPreservesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_name"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_name",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 2,
},
},
},
})
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "quarterly-report",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"file_name": "quarterly-report.pdf"`) {
t.Fatalf("stdout missing preserved file name: %s", stdout.String())
}
}
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -0,0 +1,337 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePullIfExistsOverwrite = "overwrite"
drivePullIfExistsSkip = "skip"
)
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
// DrivePull performs a one-way file-level mirror from a Drive folder onto
// a local directory: recursively lists --folder-token, downloads each
// type=file entry under --local-dir, and optionally deletes local files
// absent from Drive (--delete-local --yes).
//
// Only Drive entries with type=file participate; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to write back. Directories are reproduced when
// remote folders contain downloadable files, but local directories that
// become orphaned after a remote folder is removed are NOT pruned —
// --delete-local only unlinks regular files.
var DrivePull = common.Shortcut{
Service: "drive",
Command: "+pull",
Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
},
Tips: []string{
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
ifExists = drivePullIfExistsOverwrite
}
deleteLocal := runtime.Bool("delete-local")
// Resolve --local-dir to its canonical absolute path before we
// touch the filesystem. SafeInputPath fully evaluates symlinks
// across the entire path; this matters because filepath.Clean
// alone shrinks "link/.." to "." while the kernel resolves it
// through the symlink target's parent — meaning a raw walk on
// the user-supplied string can land outside cwd. Walking the
// canonical root sidesteps that, and using cwd canonical lets
// us emit cwd-relative download targets that FileIO.Save's
// SafeOutputPath check still accepts. The risk is much higher
// here than in +status because --delete-local would otherwise
// remove the wrong files outside cwd.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
// rootRelToCwd is the localDir form FileIO.Save accepts (it
// rejects absolute paths). For cwd itself it becomes ".", which
// joins cleanly with the rel_paths returned by the lister.
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// Two views over the same listing:
// - remoteFiles drives the download/skip loop (only type=file
// has hashable bytes the local mirror can write back).
// - remotePaths is the --delete-local guard: it carries every
// rel_path Drive owns regardless of type, so a local file
// shadowed by a remote folder / online doc / shortcut is NOT
// treated as orphaned.
remoteFiles := make(map[string]string, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
for rel, entry := range entries {
remotePaths[rel] = struct{}{}
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
}
var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0
items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability.
downloadablePaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
downloadablePaths = append(downloadablePaths, p)
}
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
token := remoteFiles[rel]
target := filepath.Join(rootRelToCwd, rel)
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
// Mirror conflict: remote is a regular file but local
// has a directory at the same rel_path. Neither
// "skipped" nor "downloaded" describes reality —
// SafeOutputPath would refuse to write a file over a
// directory, and pretending the directory is a
// pre-existing file under --if-exists=skip silently
// hides the conflict. Surface as a failure.
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: token,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
failed++
downloadFailed++
continue
}
if ifExists == drivePullIfExistsSkip {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
skipped++
continue
}
}
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
failed++
downloadFailed++
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
downloaded++
}
// Gate --delete-local on a clean download pass. With download
// failures still in items[], proceeding to the delete walk would
// leave the mirror in a half-synced state where some files Drive
// owns are missing locally AND some local-only files have been
// removed. Surface the failure first; the operator can re-run
// after fixing whatever caused the download error.
if deleteLocal && downloadFailed == 0 {
// Walk the canonical absolute root, build the list of
// rel_paths, then delete via the absolute path. Both
// values come from the validated safeRoot, so kernel
// path resolution cannot redirect the delete to a file
// outside the canonical subtree.
localAbsPaths, err := drivePullWalkLocal(safeRoot)
if err != nil {
return err
}
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
failed++
continue
}
rel = filepath.ToSlash(rel)
// Consult remotePaths (every Drive entry, regardless of
// type) rather than remoteFiles (downloadable subset
// only). Otherwise an online doc / shortcut at e.g.
// "notes.docx" would leave a same-named local file
// looking orphaned and get unlinked even though Drive
// still knows about that path.
if _, ok := remotePaths[rel]; ok {
continue
}
// FileIO has no Remove(); the absolute path comes from
// walking safeRoot, which validate.SafeInputPath has
// already bounded inside cwd, so a bare os.Remove is
// acceptable here. Shortcuts cannot import internal/vfs
// directly (depguard rule shortcuts-no-vfs).
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
deletedLocal++
}
}
payload := map[string]interface{}{
"summary": map[string]interface{}{
"downloaded": downloaded,
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
},
"items": items,
}
// Item-level failures (download error, dir/file conflict, delete
// error) must surface as a non-zero exit so AI / script callers
// don't have to reach into summary.failed to detect a partial
// sync. The same structured payload rides along in error.detail
// so forensics aren't lost. When --delete-local was skipped
// because of an earlier download failure, callers see
// deleted_local=0 plus the download failure that aborted it,
// which is what makes the partial state self-explanatory.
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
if deleteLocal && downloadFailed > 0 {
msg += " (--delete-local was skipped because the download pass had failures)"
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
}
runtime.Out(payload, nil)
return nil
},
}
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
return nil
}
// drivePullWalkLocal walks the canonical absolute root and returns the
// absolute paths of every regular file underneath it. The caller deletes
// some of these paths, so it is critical that they are produced by
// walking a canonical root (no symlinks in the path) — otherwise OS path
// resolution could redirect a delete to a file outside cwd. Same threat
// model as drive_status.go.
func drivePullWalkLocal(root string) ([]string, error) {
var paths []string
// FileIO has no walker today; shortcuts cannot import internal/vfs
// (depguard rule shortcuts-no-vfs). The root passed in is the
// canonical absolute path returned by validate.SafeInputPath, so
// WalkDir's default "do not follow child symlinks" policy keeps the
// traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
paths = append(paths, absPath)
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
return paths, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,717 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePushIfExistsOverwrite = "overwrite"
drivePushIfExistsSkip = "skip"
)
type drivePushItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// DrivePush is a one-way, file-level mirror from a local directory onto a
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
// each rel_path uploads (or overwrites) the corresponding Drive file. With
// --delete-remote --yes, any type=file entry on Drive that has no local
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
// and folders are never deleted, so this is "file-level" mirror — the
// command does not attempt to remove remote-only directories or close gaps
// in directory structure that exists on Drive but not locally.
//
// Only Drive entries with type=file participate in upload/overwrite/delete;
// online documents have no equivalent local binary. Sub-folders are created
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
// remote tree mirrors the local tree.
//
// The overwrite path passes the existing file_token as a form field on
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
// contract in shortcuts/markdown. The Drive backend exposing that field is
// being rolled out; until rollout completes, --if-exists defaults to "skip"
// so the safe path (do not touch existing remote files) is the default and
// callers must opt into "overwrite" explicitly.
var DrivePush = common.Shortcut{
Service: "drive",
Command: "+push",
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
Risk: "write",
// Narrowed scopes follow the precedent set by drive +status / +pull:
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
// to the smallest set the *core* path needs. space:folder:create is
// always declared because mirroring a non-flat tree calls
// /open-apis/drive/v1/files/create_folder on demand and we want the
// framework's pre-flight scope check to catch missing grants before any
// upload — otherwise a partial push could land top-level files and then
// trip on a missing folder grant for a sub-tree, leaving a half-synced
// state.
//
// space:document:delete is intentionally NOT in the default set even
// though --delete-remote needs it. The framework pre-check (runner.go
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
// so declaring it here would make every plain push (and every
// --dry-run) fail for callers that only granted upload scopes.
//
// Instead, Validate runs a *conditional* pre-flight via
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
// the missing grant fails the run upfront — before any upload —
// rather than landing files first and tripping on missing_scope when
// the cleanup pass tries to delete. That avoids the half-synced state
// (files uploaded, orphans never cleaned up) that the unconditional
// pre-check would otherwise prevent only by also blocking plain
// pushes.
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
},
Tips: []string{
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
}
// Conditional scope pre-check: when --delete-remote --yes is set, the
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
// upload phase. The default Scopes list intentionally omits
// space:document:delete so plain pushes don't get blocked on a grant
// they don't need (see the Scopes block above), but at this point we
// know the run will need it — pre-flight here so a missing grant
// fails before any upload, instead of after, which would otherwise
// leave the tenant in a half-synced state (files uploaded, remote
// orphans never cleaned up). EnsureScopes is a silent no-op when no
// token / scope metadata is available, so test envs and tenants
// where the resolver doesn't expose scopes still proceed and rely on
// the API-level missing_scope error.
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
// Default to the safe "skip" policy: do not touch already-present
// remote files. Callers must pass --if-exists=overwrite to opt
// into the overwrite-with-version path that depends on the
// rolling-out upload_all `file_token`/`version` protocol field.
ifExists = drivePushIfExistsSkip
}
deleteRemote := runtime.Bool("delete-remote")
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
// which closes the kernel-level escape route that filepath.Clean
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
// resolves through link's target's parent). Walking the canonical
// root sidesteps that, and the matching cwd canonical lets each
// absolute walk hit be converted to a cwd-relative path that
// FileIO.Open's SafeInputPath check still accepts.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// Two views over the same listing:
// - remoteFiles drives upload / overwrite / orphan-delete
// decisions (only type=file entries are upload candidates;
// online docs / shortcuts are intentionally never overwritten
// or deleted by --delete-remote).
// - remoteFolders is the create_folder cache: lets the upload
// path skip create_folder when an intermediate folder already
// exists, and keeps directory recreation idempotent across
// reruns.
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
for rel, entry := range entries {
switch entry.Type {
case driveTypeFile:
remoteFiles[rel] = entry
case driveTypeFolder:
remoteFolders[rel] = entry
}
}
var uploaded, skipped, failed, deletedRemote int
items := make([]drivePushItem, 0)
// uploadFailed tracks whether any folder-creation, upload or
// overwrite step failed. The --delete-remote phase only runs when
// this stays false: a partial upload that then proceeds to delete
// remote orphans would leave the tenant half-synced (files missing
// locally and now on Drive too), which is the worst-of-both-worlds
// outcome the review flagged.
uploadFailed := false
// folderCache holds rel_path → folder_token. Seeded from the remote
// listing (so we don't recreate folders that already exist) and
// extended in-place as drivePushEnsureFolder mints new ones.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
folderCache[relDir] = entry.FileToken
}
// Mirror local directory structure first, so empty directories
// are not silently dropped. Pre-creating also frees the upload
// loop from doing on-demand mkdir for every file's parent chain
// (the cache makes both paths idempotent, but pre-creation keeps
// items[] in a tidy "folders, then files" shape).
for _, relDir := range localDirs {
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
// Folder already exists on Drive — nothing to do; staying
// silent (no items[] entry) avoids noise on reruns.
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
}
// Upload local-only and overwrite/skip already-present files in a
// stable order so output is reproducible.
localPaths := make([]string, 0, len(localFiles))
for p := range localFiles {
localPaths = append(localPaths, p)
}
sort.Strings(localPaths)
for _, rel := range localPaths {
localFile := localFiles[rel]
if entry, ok := remoteFiles[rel]; ok {
if ifExists == drivePushIfExistsSkip {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
skipped++
continue
}
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
if upErr != nil {
// Token contract on overwrite failure: an in-place
// overwrite preserves the file's token, so the
// existing entry.FileToken is normally still the
// authoritative pointer to the (possibly already
// rewritten) Drive file. But the protocol does not
// strictly forbid the backend from minting a new
// token, and a partial-success response can return a
// non-empty file_token alongside an error (the
// missing-version case below is the immediate
// concern: bytes hit the disk, version field
// missing, so we surface a structured error). Prefer
// the freshly returned token when one was produced,
// fall back to entry.FileToken otherwise — that way
// callers still have a usable handle to whatever
// state Drive ended up in.
failedToken := token
if failedToken == "" {
failedToken = entry.FileToken
}
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
uploaded++
continue
}
parentRel := drivePushParentRel(rel)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
failed++
uploadFailed = true
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
uploaded++
}
// Skip the delete phase entirely on any upstream failure. The orphan
// loop deletes by remote token and is unrecoverable; running it
// after a failed upload risks deleting a file the partial upload
// would have replaced on a successful re-run, leaving the tenant
// in a worse state than where we started. Surface the skipped
// delete as a hint in stderr so operators know the cleanup pass
// is pending and can re-run after fixing the upload.
if deleteRemote && uploadFailed {
fmt.Fprintf(runtime.IO().ErrOut,
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
failed)
}
if deleteRemote && !uploadFailed {
// Stable iteration order so failures (and tests) are deterministic.
remoteRelPaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
remoteRelPaths = append(remoteRelPaths, p)
}
sort.Strings(remoteRelPaths)
for _, rel := range remoteRelPaths {
if _, ok := localFiles[rel]; ok {
continue
}
entry := remoteFiles[rel]
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
}
runtime.Out(map[string]interface{}{
"summary": map[string]interface{}{
"uploaded": uploaded,
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
},
"items": items,
}, nil)
// Bump the exit code on any item-level failure (upload, overwrite,
// folder, or delete) so callers / scripts / agents can react. The
// summary + items[] envelope was just written to stdout via Out(),
// so ErrBare here only affects the exit code — the structured
// per-item context is still in the stdout JSON.
if failed > 0 {
return output.ErrBare(output.ExitAPI)
}
return nil
},
}
// drivePushLocalFile records what we need to upload a local regular file:
// a rel_path used for output and Drive layout, the cwd-relative path that
// FileIO.Open accepts, the file size (drives single/multipart selection),
// and the basename used as Drive's file_name.
type drivePushLocalFile struct {
RelPath string
OpenPath string
FileName string
Size int64
}
// drivePushWalkLocal walks the canonical absolute root produced by
// SafeInputPath. Same threat model as +pull/+status: the validated root
// is not a symlink itself, and WalkDir's default policy (do not follow
// child symlinks) keeps the traversal inside that canonical subtree, so
// the OpenPath we hand to FileIO.Open stays inside cwd.
//
// Returns two views:
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
// - dirs: every non-root directory rel_path encountered. Used to mirror
// empty directories (which would otherwise be silently dropped because
// the upload loop only iterates files); non-empty directories appear
// here too but are harmless because drivePushEnsureFolder is cached.
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
files := make(map[string]drivePushLocalFile)
dirsSet := make(map[string]struct{})
// FileIO has no walker today and shortcuts can't import internal/vfs
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
// absolute path returned by validate.SafeInputPath, so it is no
// longer a symlink itself, and WalkDir's default child-symlink
// policy keeps the traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
if d.IsDir() {
// Skip the root itself ("."): that is --folder-token, already
// the parent we mirror into, not a sub-folder we need to
// create.
if relSlash != "." {
dirsSet[relSlash] = struct{}{}
}
return nil
}
if !d.Type().IsRegular() {
return nil
}
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
files[relSlash] = drivePushLocalFile{
RelPath: relSlash,
OpenPath: relToCwd,
FileName: filepath.Base(rel),
Size: info.Size(),
}
return nil
})
if err != nil {
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
dirs := make([]string, 0, len(dirsSet))
for d := range dirsSet {
dirs = append(dirs, d)
}
// Shallow-first ordering ensures parents are created before children;
// drivePushEnsureFolder also handles parent recursion on its own, but
// emitting items[] in shallow-first order matches what users expect.
sort.Slice(dirs, func(i, j int) bool {
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
if di != dj {
return di < dj
}
return dirs[i] < dirs[j]
})
return files, dirs, nil
}
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
// folder identified by rootFolderToken) exists on Drive, creating any
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
// token of the deepest folder, suitable as parent_node for the upload.
//
// folderCache is shared with the caller so each segment is only created
// once per push, and so subsequent uploads under the same sub-tree reuse
// the freshly minted folder token without an extra round trip.
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
if token, ok := folderCache[relDir]; ok {
return token, nil
}
parentRel, name := drivePushSplitRel(relDir)
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
if err != nil {
return "", err
}
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
map[string]interface{}{
"name": name,
"folder_token": parentToken,
},
)
if err != nil {
return "", err
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
}
folderCache[relDir] = token
return token, nil
}
// drivePushUploadFile uploads (or overwrites) a single local file. When
// existingToken is non-empty, the request adds the file_token form field to
// trigger overwrite-with-version semantics on the backend; the response is
// expected to carry a non-empty `version`, which is propagated to the
// caller for the items[].version field. When existingToken is empty, this
// is a fresh upload under parentToken.
//
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
// the three-step prepare/part/finish flow, which mirrors drive +upload's
// existing multipart logic.
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
// Multipart finish does not return version on the existing
// /open-apis/drive/v1/files/upload_finish contract; surface an
// empty version in that case rather than fabricating one. The
// markdown +overwrite path has the same gap and is tracked for a
// follow-up once the multipart endpoint exposes the field.
return token, "", err
}
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
}
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", "", common.WrapInputStatError(err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", file.FileName)
fd.AddField("parent_type", driveUploadParentTypeExplorer)
fd.AddField("parent_node", parentToken)
fd.AddField("size", fmt.Sprintf("%d", file.Size))
if existingToken != "" {
// Overwrite mode: the backend interprets a non-empty file_token on
// upload_all as "replace this file's content and bump its version",
// matching the markdown +overwrite contract.
fd.AddField("file_token", existingToken)
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", "", err
}
return "", "", output.ErrNetwork("upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
// Extract the token before the larkCode check: the backend can produce
// a partial-success response (code != 0 alongside a non-empty
// data.file_token) where bytes have already landed under that token.
// Returning "" here would force the caller to fall back to
// entry.FileToken and silently lose the token Drive actually used,
// defeating the overwrite-error token-stability handling in Execute.
data, _ := result["data"].(map[string]interface{})
token := common.GetString(data, "file_token")
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
if token == "" {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
version := common.GetString(data, "version")
if version == "" {
// Some backends return the version under data_version; accept either
// per the markdown +overwrite contract.
version = common.GetString(data, "data_version")
}
if existingToken != "" && version == "" {
// The protocol guarantees a non-empty version on overwrite. If the
// deployed backend hasn't shipped the field yet we surface the gap
// rather than report a phantom success — callers can downgrade to
// --if-exists=skip in the meantime.
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
}
return token, version, nil
}
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
prepareBody := map[string]interface{}{
"file_name": file.FileName,
"parent_type": driveUploadParentTypeExplorer,
"parent_node": parentToken,
"size": file.Size,
}
if existingToken != "" {
prepareBody["file_token"] = existingToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
uploadID := common.GetString(prepareResult, "upload_id")
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
blockNum := int(common.GetFloat(prepareResult, "block_num"))
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
// Open the local file ONCE for the whole multipart loop. fileio.File
// implements io.ReaderAt, so each block is a fresh
// io.NewSectionReader over a shared fd — no need to reopen N times
// (which is what drive +upload's existing multipart helper does and
// what the original drive_push copy inherited; that pattern wastes
// one Open + Close + path-validation per block).
partFile, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer partFile.Close()
for seq := 0; seq < blockNum; seq++ {
offset := int64(seq) * blockSize
partSize := blockSize
if remaining := file.Size - offset; partSize > remaining {
partSize = remaining
}
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", partSize))
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if doErr != nil {
var exitErr *output.ExitError
if errors.As(doErr, &exitErr) {
return "", doErr
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
token := common.GetString(finishResult, "file_token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
}
return token, nil
}
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
// never reached here because --delete-remote only iterates the type=file
// subset of the remote listing.
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
_, err := runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
map[string]interface{}{"type": driveTypeFile},
nil,
)
return err
}
// drivePushParentRel returns the parent rel_path of rel ("" when the file
// lives at the root). The local walker emits forward-slash rel_paths so
// path.Dir is the right primitive here, not filepath.Dir.
func drivePushParentRel(rel string) string {
dir := path.Dir(rel)
if dir == "." || dir == "/" {
return ""
}
return dir
}
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
// using forward slashes.
func drivePushSplitRel(rel string) (string, string) {
idx := strings.LastIndex(rel, "/")
if idx < 0 {
return "", rel
}
return rel[:idx], rel[idx+1:]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveStatusEntry struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
}
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
//
// Only Drive entries with type=file are compared; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to hash against.
//
// SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any
// path that resolves outside cwd, which keeps the local side bounded to the
// caller's working directory.
var DriveStatus = common.Shortcut{
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by content hash",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
},
Tips: []string{
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
// Path safety (absolute paths, traversal, symlink escape) is enforced
// upfront by the framework helper so the error message references the
// correct flag name; FileIO().Stat below would do the same check, but
// surface --file in its hint.
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
// which closes the kernel-level escape route that filepath.Clean
// alone misses: e.g. "link/.." string-cleans to "." but the kernel
// resolves through link's target's parent, so a raw walk on the
// user-supplied string can land outside cwd. Walking the canonical
// root sidesteps that — and the matching cwd canonical lets each
// absolute walk hit be converted to a cwd-relative path that
// FileIO.Open's SafeInputPath check still accepts.
//
// Validate already ran SafeLocalFlagPath (with the proper flag
// name in the error message), so a failure here is unexpected and
// only possible under a Validate↔Execute race.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// +status only diffs binary content, so collapse the unified
// listing to type=file. Online docs / shortcuts have no
// hashable bytes and are intentionally absent from the diff
// view (a docx living next to a same-named local file is a
// known no-op).
remoteFiles := make(map[string]string, len(entries))
for rel, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
}
paths := mergeStatusPaths(localHashes, remoteFiles)
var newLocal, newRemote, modified, unchanged []driveStatusEntry
for _, relPath := range paths {
localHash, hasLocal := localHashes[relPath]
remoteToken, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
default:
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
if err != nil {
return err
}
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
}
}
runtime.Out(map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
}, nil)
return nil
},
}
// walkLocalForStatus walks the canonical absolute root produced by
// SafeInputPath. Using the canonical root keeps the kernel from
// following any symlink hidden inside the user-supplied --local-dir
// (e.g. "link/..", which filepath.Clean shrinks to "." but which OS
// path resolution would resolve through the symlink target). For each
// hit, we report rel_path relative to root for the JSON output, and
// convert the absolute path to a cwd-relative form so FileIO.Open's
// SafeInputPath check (which rejects absolute paths) still applies.
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
files := make(map[string]string)
// FileIO has no walker today and shortcuts can't import internal/vfs.
// The walk root is the canonical absolute path returned by
// validate.SafeInputPath, so it is no longer a symlink itself, and
// WalkDir's default policy (do not follow child symlinks) keeps the
// traversal inside that canonical subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return err
}
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
if err != nil {
return err
}
sum, err := hashLocalForStatus(runtime, relToCwd)
if err != nil {
return err
}
files[filepath.ToSlash(rel)] = sum
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
return files, nil
}
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
h := sha256.New()
if _, err := io.Copy(h, resp.Body); err != nil {
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func mergeStatusPaths(local, remote map[string]string) []string {
seen := make(map[string]struct{}, len(local)+len(remote))
for p := range local {
seen[p] = struct{}{}
}
for p := range remote {
seen[p] = struct{}{}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out
}
func emptyIfNil(s []driveStatusEntry) []driveStatusEntry {
if s == nil {
return []driveStatusEntry{}
}
return s
}

View File

@@ -0,0 +1,498 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
// against a real walk of the temp dir and a mocked Drive listing.
func TestDriveStatusCategorizesByHash(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
// Local layout:
// local/a.txt — also on remote with different content → modified
// local/b.txt — only local → new_local
// local/sub/c.txt — also on remote with same content → unchanged
// Remote-only:
// d.txt → new_remote
if err := os.MkdirAll("local/sub", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("aaa"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
t.Fatalf("WriteFile b.txt: %v", err)
}
if err := os.WriteFile("local/sub/c.txt", []byte("ccc"), 0o644); err != nil {
t.Fatalf("WriteFile sub/c.txt: %v", err)
}
// Root folder list — order matters: stubs match in registration order.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"},
// noise: an online doc and a shortcut should be ignored
map[string]interface{}{"token": "tok_doc", "name": "ignored.docx", "type": "docx"},
map[string]interface{}{"token": "tok_sc", "name": "ignored.lnk", "type": "shortcut"},
},
"has_more": false,
},
},
})
// Subfolder list
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=tok_sub",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file"},
},
"has_more": false,
},
},
})
// Download a.txt: remote content differs from local "aaa" → modified.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("AAA"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
// Download c.txt: remote content matches local "ccc" → unchanged.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_c/download",
Status: 200,
Body: []byte("ccc"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
checks := []struct {
bucket string
path string
token string
}{
{"new_local", "b.txt", ""},
{"new_remote", "d.txt", "tok_d"},
{"modified", "a.txt", "tok_a"},
{"unchanged", "sub/c.txt", "tok_c"},
}
for _, c := range checks {
if !strings.Contains(out, `"`+c.bucket+`":`) {
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
}
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
}
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
}
}
if strings.Contains(out, "ignored.docx") || strings.Contains(out, "ignored.lnk") {
t.Errorf("output should skip docx/shortcut entries\noutput: %s", out)
}
reg.Verify(t)
}
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
// (what the shared helper also accepts). If the shortcut had hard-coded
// either field name, one of the two pages' files would be silently dropped
// from the comparison and would land in the wrong bucket. Stub order is
// significant: httpmock matches in registration order, and both stubs key on
// the GET .../files URL — they pop in turn, so page 1's response (with the
// continuation token) must be registered before page 2's terminator.
func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
// Page 1: returns one file plus a continuation token via
// next_page_token (the field Drive currently emits).
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_p1", "name": "page1.txt", "type": "file"},
},
"has_more": true,
"next_page_token": "cursor-page-2",
},
},
})
// Page 2: returns the second file with has_more=false. This stub uses
// page_token (the alternate spelling) to lock in that the shared
// PaginationMeta helper accepts BOTH field names.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_p2", "name": "page2.txt", "type": "file"},
},
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
// Both pages contributed to new_remote (local is empty).
for _, want := range []string{
`"rel_path": "page1.txt"`,
`"file_token": "tok_p1"`,
`"rel_path": "page2.txt"`,
`"file_token": "tok_p2"`,
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q (a page must have been silently dropped)\noutput: %s", want, out)
}
}
reg.Verify(t)
}
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "does-not-exist",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing local dir, got nil")
}
}
func TestDriveStatusRejectsLocalFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "not-a-dir.txt",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error when --local-dir is a file, got nil")
}
if !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestDriveStatusRejectsAbsoluteLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "/etc",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for absolute --local-dir, got nil")
}
}
// TestDriveStatusRejectsEmptyFolderToken covers the Validate-stage required
// check that runs before ResourceName: an empty --folder-token must surface
// a structured FlagError referencing the flag name.
func TestDriveStatusRejectsEmptyFolderToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for empty --folder-token, got nil")
}
if !strings.Contains(err.Error(), "--folder-token") {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}
// TestDriveStatusDoesNotEscapeViaSymlinkParentRef is the regression for the
// "link/.." escape: filepath.Clean string-shrinks "link/.." to ".", so a
// raw walk on the user-supplied input can land on the kernel-resolved
// path through link's target's parent — outside cwd. The fix is to walk
// SafeInputPath's canonical absolute root instead of the raw input.
//
// Setup: an "escape" sibling directory contains a sentinel file; cwd
// contains a "link" symlink pointing into that escape directory.
// Calling +status with --local-dir "link/.." must not surface the
// sentinel — the walk must stay inside cwd.
func TestDriveStatusDoesNotEscapeViaSymlinkParentRef(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Sentinel lives outside cwd; the agent must never see it.
escapeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
// cwd has a symlink that points into the sentinel's parent.
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.Symlink(escapeDir, filepath.Join(cwdDir, "link")); err != nil {
t.Fatalf("Symlink: %v", err)
}
// A normal file inside cwd just to make the walk non-trivial.
if err := os.WriteFile(filepath.Join(cwdDir, "ok.txt"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile ok: %v", err)
}
// Empty remote folder so any path that surfaces in the output
// must have come from the local walk.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "link/..",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
t.Fatalf("walk escaped via link/..: secret.txt leaked into output\noutput:\n%s", out)
}
// ok.txt is in cwd and must classify as new_local (no remote stub for it).
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
t.Fatalf("expected ok.txt in new_local, got:\n%s", out)
}
}
// TestDriveStatusSkipsSymlinkInsideRoot pins down WalkDir's default policy
// for symlinks discovered as child entries: they are reported with a
// non-regular file mode and the callback skips them, so a symlink inside
// the validated root pointing into an out-of-tree directory cannot leak
// the target's contents.
func TestDriveStatusSkipsSymlinkInsideRoot(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Sentinel sits outside cwd; a child symlink inside the walked root
// points there. If the walker followed child symlinks (it must not),
// the sentinel's name would surface in new_local.
escapeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "ok.txt"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile ok: %v", err)
}
// Child-of-root symlink that resolves out of the validated subtree.
if err := os.Symlink(escapeDir, filepath.Join("local", "sub", "escape")); err != nil {
t.Fatalf("Symlink: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
t.Fatalf("walk followed child symlink and leaked sentinel:\n%s", out)
}
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
t.Fatalf("expected ok.txt in new_local; got:\n%s", out)
}
}
// TestDriveStatusSurvivesCircularSymlinkInsideRoot makes sure WalkDir
// terminates even when a child symlink points back at one of its
// ancestors. WalkDir's default policy already declines to follow child
// symlinks; this test pins that contract for our caller.
func TestDriveStatusSurvivesCircularSymlinkInsideRoot(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "real.txt"), []byte("real"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// loop symlink: cwd/local/sub/loop -> cwd/local (an ancestor).
loopTarget, err := filepath.Abs(filepath.Join("local"))
if err != nil {
t.Fatalf("Abs: %v", err)
}
if err := os.Symlink(loopTarget, filepath.Join("local", "sub", "loop")); err != nil {
t.Fatalf("Symlink: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{},
"has_more": false,
},
},
})
// If WalkDir followed the loop, this test would never finish; the
// test runner's per-test timeout would surface that as a failure.
err = mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
if !strings.Contains(stdout.String(), `"rel_path": "sub/real.txt"`) {
t.Fatalf("expected sub/real.txt in new_local; got:\n%s", stdout.String())
}
}
// TestDriveStatusRejectsMalformedFolderToken covers the ResourceName format
// guard: a token with control characters (newline) must be rejected before
// any API call is made.
func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "tok\nwithnewline",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for malformed --folder-token, got nil")
}
if !strings.Contains(err.Error(), "--folder-token") {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListRemotePageSize = 200
driveTypeFile = "file"
driveTypeFolder = "folder"
)
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
// carries enough metadata for every shortcut that consumes the listing
// to build its own per-shortcut view by filtering on Type.
type driveRemoteEntry struct {
// FileToken is the Drive token for this entry. For type=folder this
// is the folder_token; for everything else it is the file_token.
FileToken string
// Type is the Drive entry kind verbatim from the API:
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
// "mindnote" | "slides" | "shortcut" | …
Type string
// RelPath is the entry's path relative to the listing root. Encoded
// with "/" separators on every platform so it matches the rel_paths
// produced by the shortcuts' local walkers.
RelPath string
}
// listRemoteFolder recursively lists folderToken under relBase and
// returns one entry per Drive item, keyed by rel_path. Subfolders are
// descended into and the folder's own entry is also recorded — callers
// can reason about "this rel_path is occupied by a folder" without
// re-listing.
//
// This is the shared backbone for the three sync-disk shortcuts. None
// of them need every field at every call site, so each one filters
// on Type:
//
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
// to drive content-hash diffs against the local tree.
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
// download set, and the full key set (every rel_path) as the
// guard for --delete-local.
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
// for the create_folder cache.
//
// Pagination uses common.PaginationMeta, which accepts both
// page_token and next_page_token — the Drive list endpoint has
// historically returned the latter, but the helper future-proofs
// against a backend rename.
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
out := make(map[string]driveRemoteEntry)
pageToken := ""
for {
params := map[string]interface{}{
"folder_token": folderToken,
"page_size": fmt.Sprint(driveListRemotePageSize),
}
if pageToken != "" {
params["page_token"] = pageToken
}
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
if err != nil {
return nil, err
}
rawFiles, _ := result["files"].([]interface{})
for _, item := range rawFiles {
f, ok := item.(map[string]interface{})
if !ok {
continue
}
fType := common.GetString(f, "type")
fName := common.GetString(f, "name")
fToken := common.GetString(f, "token")
if fName == "" || fToken == "" {
continue
}
rel := joinRelDrive(relBase, fName)
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
if fType == driveTypeFolder {
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
if err != nil {
return nil, err
}
for k, v := range sub {
out[k] = v
}
}
}
hasMore, nextToken := common.PaginationMeta(result)
if !hasMore || nextToken == "" {
break
}
pageToken = nextToken
}
return out, nil
}
// joinRelDrive joins a rel_path base with an entry name using "/".
// Empty base means the entry sits at the listing root. Mirrors the
// behavior the per-shortcut helpers used to ship and keeps rel_paths
// stable across +status / +pull / +push.
func joinRelDrive(base, name string) string {
if base == "" {
return name
}
return base + "/" + name
}

View File

@@ -18,6 +18,9 @@ func Shortcuts() []common.Shortcut {
DriveImport,
DriveMove,
DriveDelete,
DriveStatus,
DrivePush,
DrivePull,
DriveTaskResult,
DriveApplyPermission,
DriveSearch,

View File

@@ -21,6 +21,9 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+import",
"+move",
"+delete",
"+status",
"+push",
"+pull",
"+task_result",
"+apply-permission",
"+search",

View File

@@ -9,6 +9,7 @@ import (
"mime"
"net/mail"
"strings"
"time"
"github.com/larksuite/cli/extension/fileio"
)
@@ -215,11 +216,24 @@ type PatchOp struct {
Target AttachmentTarget `json:"target,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
// Calendar event fields, used by set_calendar. The raw ISO 8601 strings
// are shown in dry-run output; the shortcut layer pre-builds the ICS
// blob into CalendarICS below before Apply runs.
EventSummary string `json:"event_summary,omitempty"`
EventStart string `json:"event_start,omitempty"`
EventEnd string `json:"event_end,omitempty"`
EventLocation string `json:"event_location,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:"-"`
// CalendarICS holds the pre-built RFC 5545 ICS blob for a set_calendar
// op. Populated by the shortcut layer after the snapshot is parsed and
// organizer/attendee addresses can be resolved. Not serialised.
CalendarICS []byte `json:"-"`
}
// SignatureImage holds pre-downloaded image data for signature inline images.
@@ -327,6 +341,26 @@ func (op PatchOp) Validate() error {
}
case "remove_signature":
// No required fields.
case "set_calendar":
if strings.TrimSpace(op.EventSummary) == "" {
return fmt.Errorf("set_calendar requires event_summary")
}
if strings.TrimSpace(op.EventStart) == "" || strings.TrimSpace(op.EventEnd) == "" {
return fmt.Errorf("set_calendar requires event_start and event_end")
}
start, err := parseISO8601(op.EventStart)
if err != nil {
return fmt.Errorf("set_calendar: event_start must be a valid ISO 8601 timestamp")
}
end, err := parseISO8601(op.EventEnd)
if err != nil {
return fmt.Errorf("set_calendar: event_end must be a valid ISO 8601 timestamp")
}
if !end.After(start) {
return fmt.Errorf("set_calendar: event_end must be after event_start")
}
case "remove_calendar":
// No required fields.
default:
return fmt.Errorf("unsupported op %q", op.Op)
}
@@ -400,3 +434,19 @@ func MustJSON(v interface{}) string {
}
return string(data)
}
// parseISO8601 tries common ISO 8601 timestamp layouts, accepting both
// with-seconds (RFC 3339) and without-seconds variants.
func parseISO8601(s string) (time.Time, error) {
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}

View File

@@ -136,6 +136,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
case "set_calendar":
return applyCalendarSet(snapshot, op.CalendarICS)
case "remove_calendar":
return applyCalendarRemove(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"fmt"
"strings"
)
const calendarMediaType = "text/calendar"
// applyCalendarSet installs or replaces the text/calendar MIME part in the
// snapshot. The caller is expected to have pre-built icsData using the
// snapshot's From/To/Cc addresses.
func applyCalendarSet(snapshot *DraftSnapshot, icsData []byte) error {
if len(icsData) == 0 {
return fmt.Errorf("set_calendar: ICS data is empty (shortcut layer must pre-build it)")
}
setCalendarPart(snapshot, icsData)
return nil
}
// applyCalendarRemove strips the text/calendar part from the snapshot.
// No-op if no calendar part exists.
func applyCalendarRemove(snapshot *DraftSnapshot) error {
removeCalendarPart(snapshot)
return nil
}
// setCalendarPart places exactly one text/calendar part inside
// multipart/alternative, matching the Feishu client behavior. Any existing
// text/calendar parts elsewhere in the tree are removed first.
func setCalendarPart(snapshot *DraftSnapshot, icsData []byte) {
newPart := &Part{
MediaType: calendarMediaType,
MediaParams: map[string]string{"charset": "UTF-8", "method": "REQUEST"},
Body: icsData,
Dirty: true,
}
if snapshot.Body == nil {
snapshot.Body = newPart
return
}
// Remove all existing text/calendar parts from everywhere in the tree.
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
snapshot.Body = newPart
return
}
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
// Place inside the existing multipart/alternative.
if alt := FindPartByMediaType(snapshot.Body, "multipart/alternative"); alt != nil {
alt.Children = append(alt.Children, newPart)
alt.Dirty = true
return
}
// No multipart/alternative exists. If the body is a single leaf,
// wrap it in multipart/alternative together with the calendar.
if !snapshot.Body.IsMultipart() {
original := *snapshot.Body
// Reset all header-carrying fields so the serializer constructs a fresh
// Content-Type from MediaType instead of reusing the stale leaf headers.
snapshot.Body.Headers = nil
snapshot.Body.MediaType = "multipart/alternative"
snapshot.Body.MediaParams = nil
snapshot.Body.ContentDisposition = ""
snapshot.Body.ContentDispositionArg = nil
snapshot.Body.ContentID = ""
snapshot.Body.PartID = ""
snapshot.Body.Body = nil
snapshot.Body.TransferEncoding = ""
snapshot.Body.RawEntity = nil
snapshot.Body.Preamble = nil
snapshot.Body.Epilogue = nil
snapshot.Body.EncodingProblem = false
snapshot.Body.Children = []*Part{&original, newPart}
snapshot.Body.Dirty = true
return
}
// Multipart body without an alternative sub-part (e.g. multipart/mixed
// with a text/html child). Find the first text/* child and wrap it in
// a new multipart/alternative that also contains the calendar.
for i, child := range snapshot.Body.Children {
if child != nil && strings.HasPrefix(strings.ToLower(child.MediaType), "text/") {
alt := &Part{
MediaType: "multipart/alternative",
Children: []*Part{child, newPart},
Dirty: true,
}
snapshot.Body.Children[i] = alt
snapshot.Body.Dirty = true
return
}
}
// Fallback: append to the root multipart container.
snapshot.Body.Children = append(snapshot.Body.Children, newPart)
snapshot.Body.Dirty = true
}
func removeCalendarPart(snapshot *DraftSnapshot) {
if snapshot.Body == nil {
return
}
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
snapshot.Body = nil
return
}
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
}
// FindPartByMediaType walks the MIME tree and returns the first part with
// the given media type, or nil when not found.
func FindPartByMediaType(root *Part, mediaType string) *Part {
if root == nil {
return nil
}
if strings.EqualFold(root.MediaType, mediaType) {
return root
}
for _, child := range root.Children {
if found := FindPartByMediaType(child, mediaType); found != nil {
return found
}
}
return nil
}
// findAllPartsByMediaType walks the MIME tree and returns every part with
// the given media type. Used in tests to assert tree contents.
func findAllPartsByMediaType(root *Part, mediaType string) []*Part {
if root == nil {
return nil
}
var result []*Part
if strings.EqualFold(root.MediaType, mediaType) {
result = append(result, root)
}
for _, child := range root.Children {
result = append(result, findAllPartsByMediaType(child, mediaType)...)
}
return result
}
// removePartByMediaType removes the first part with the given media type from
// the MIME tree. The parent is marked dirty when a removal happens.
func removePartByMediaType(root *Part, mediaType string) {
if root == nil {
return
}
for i, child := range root.Children {
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
root.Dirty = true
return
}
removePartByMediaType(child, mediaType)
}
}
// removeAllPartsByMediaType removes every part with the given media type from
// the MIME tree, at all nesting levels.
func removeAllPartsByMediaType(root *Part, mediaType string) {
if root == nil {
return
}
var kept []*Part
removed := false
for _, child := range root.Children {
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
removed = true
continue
}
kept = append(kept, child)
}
if removed {
root.Children = kept
root.Dirty = true
}
for _, child := range root.Children {
removeAllPartsByMediaType(child, mediaType)
}
}

View File

@@ -0,0 +1,429 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
const fixtureCalData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
// ---------------------------------------------------------------------------
// set_calendar — validate
// ---------------------------------------------------------------------------
func TestSetCalendar_ValidateRequiresSummary(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventStart: "2026-04-25T10:00+08:00", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_summary") {
t.Errorf("expected event_summary error, got %v", err)
}
}
func TestSetCalendar_ValidateRequiresStartAndEnd(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "Meeting", EventStart: "2026-04-25T10:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_start and event_end") {
t.Errorf("expected start/end error, got %v", err)
}
}
func TestSetCalendar_ValidateInvalidStartFormat(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "not-a-date", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_start") {
t.Errorf("expected event_start error for bad format, got %v", err)
}
}
func TestSetCalendar_ValidateInvalidEndFormat(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T10:00+08:00", EventEnd: "not-a-date"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_end") {
t.Errorf("expected event_end error for bad format, got %v", err)
}
}
func TestSetCalendar_ValidateEndNotAfterStart(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T11:00+08:00", EventEnd: "2026-04-25T10:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "after") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestSetCalendar_ValidateOK(t *testing.T) {
err := PatchOp{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
}.Validate()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply adds text/calendar part when none exists
// ---------------------------------------------------------------------------
func TestSetCalendar_AddsCalendarPartToHTMLDraft(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
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: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: []byte(fixtureCalData),
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
if part == nil {
t.Fatal("text/calendar part not added to draft")
}
if string(part.Body) != fixtureCalData {
t.Errorf("calendar part body mismatch: got %q", part.Body)
}
if part.MediaParams["method"] != "REQUEST" {
t.Errorf("calendar part missing method=REQUEST in MediaParams: %v", part.MediaParams)
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply replaces existing text/calendar part
// ---------------------------------------------------------------------------
func TestSetCalendar_ReplacesExistingCalendarPart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1"
--b1
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--b1
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
VERSION:2.0
SUMMARY:OLD
END:VCALENDAR
--b1--`)
newICS := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "NEW",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: newICS,
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
if part == nil {
t.Fatal("text/calendar part missing")
}
if !strings.Contains(string(part.Body), "SUMMARY:NEW") {
t.Errorf("expected new SUMMARY, got %q", part.Body)
}
if strings.Contains(string(part.Body), "SUMMARY:OLD") {
t.Errorf("old SUMMARY not replaced")
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply requires pre-built ICS
// ---------------------------------------------------------------------------
func TestSetCalendar_EmptyICSIsError(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
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: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
// CalendarICS intentionally nil — simulates missing pre-process.
}},
})
if err == nil {
t.Fatal("expected error for missing CalendarICS")
}
if !strings.Contains(err.Error(), "ICS data is empty") {
t.Errorf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// remove_calendar
// ---------------------------------------------------------------------------
func TestRemoveCalendar_StripsCalendarPart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1"
--b1
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--b1
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
END:VCALENDAR
--b1--`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if part := FindPartByMediaType(snapshot.Body, calendarMediaType); part != nil {
t.Errorf("text/calendar part should be removed, but still found")
}
}
func TestRemoveCalendar_NoOpWhenAbsent(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain
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: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
// Body remains intact.
if snapshot.Body == nil {
t.Fatal("body unexpectedly nil")
}
}
// ---------------------------------------------------------------------------
// Internal MIME helpers (coverage)
// ---------------------------------------------------------------------------
func TestFindPartByMediaType_CaseInsensitive(t *testing.T) {
root := &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "TEXT/Calendar"},
},
}
got := FindPartByMediaType(root, "text/calendar")
if got == nil {
t.Fatal("expected to find part despite case mismatch")
}
}
func TestRemovePartByMediaType_MarksParentDirty(t *testing.T) {
root := &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "text/calendar"},
{MediaType: "text/html"},
},
}
removePartByMediaType(root, "text/calendar")
if len(root.Children) != 1 {
t.Fatalf("expected 1 remaining child, got %d", len(root.Children))
}
if !root.Dirty {
t.Error("parent not marked dirty after removal")
}
}
func TestSetCalendar_CollapsesToOneInsideAlternative(t *testing.T) {
// Feishu client creates two text/calendar copies: one inside
// multipart/alternative and one as an inline attachment in
// multipart/mixed. set_calendar must collapse them to a single
// copy inside multipart/alternative.
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="outer"
--outer
Content-Type: multipart/alternative; boundary="inner"
--inner
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--inner
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
SUMMARY:OLD
END:VCALENDAR
--inner--
--outer
Content-Type: text/calendar; charset=UTF-8; name="invite.ics"
Content-Id: <invite.ics>
BEGIN:VCALENDAR
SUMMARY:OLD
END:VCALENDAR
--outer--`)
newICS := []byte("BEGIN:VCALENDAR\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "NEW",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: newICS,
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
// Exactly one text/calendar part should remain, inside alternative.
parts := findAllPartsByMediaType(snapshot.Body, calendarMediaType)
if len(parts) != 1 {
t.Fatalf("expected 1 text/calendar part, got %d", len(parts))
}
if !strings.Contains(string(parts[0].Body), "SUMMARY:NEW") {
t.Errorf("expected SUMMARY:NEW, got %q", parts[0].Body)
}
// The calendar part must be a child of multipart/alternative.
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
if alt == nil {
t.Fatal("multipart/alternative not found")
}
found := false
for _, child := range alt.Children {
if strings.EqualFold(child.MediaType, calendarMediaType) {
found = true
}
}
if !found {
t.Error("text/calendar part not inside multipart/alternative")
}
}
func TestRemoveCalendar_RootLevelCalendarBody(t *testing.T) {
// When the snapshot body is itself a text/calendar leaf (no multipart
// wrapper), removeCalendarPart must nil out snapshot.Body rather than
// trying to remove it from a parent's children slice.
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "text/calendar",
Body: []byte(fixtureCalData),
},
}
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if snapshot.Body != nil {
t.Errorf("snapshot.Body should be nil after removing root-level text/calendar, got %+v", snapshot.Body)
}
}
func TestSetCalendarPart_OnNilBodyCreatesLeaf(t *testing.T) {
snapshot := &DraftSnapshot{}
setCalendarPart(snapshot, []byte(fixtureCalData))
if snapshot.Body == nil {
t.Fatal("body should be created")
}
if !strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
t.Errorf("expected %s leaf, got %s", calendarMediaType, snapshot.Body.MediaType)
}
}
func TestSetCalendarPart_MixedWithoutAlternativeWrapsTextChild(t *testing.T) {
// multipart/mixed with a text/html child but no alternative sub-part.
// setCalendarPart should wrap the text/html in a new alternative.
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "text/html", Body: []byte("<p>Hi</p>")},
{MediaType: "application/pdf", Body: []byte("pdf-data")},
},
},
}
setCalendarPart(snapshot, []byte(fixtureCalData))
if snapshot.Body.MediaType != "multipart/mixed" {
t.Fatalf("root should stay multipart/mixed, got %s", snapshot.Body.MediaType)
}
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
if alt == nil {
t.Fatal("expected a multipart/alternative child to be created")
}
if len(alt.Children) != 2 {
t.Fatalf("alternative should have 2 children, got %d", len(alt.Children))
}
if !strings.EqualFold(alt.Children[0].MediaType, "text/html") {
t.Errorf("first alternative child should be text/html, got %s", alt.Children[0].MediaType)
}
if !strings.EqualFold(alt.Children[1].MediaType, calendarMediaType) {
t.Errorf("second alternative child should be text/calendar, got %s", alt.Children[1].MediaType)
}
}
func TestSetCalendarPart_FallbackAppendsToMultipart(t *testing.T) {
// multipart/mixed with only non-text children (no text/* to wrap).
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "application/pdf", Body: []byte("pdf-data")},
},
},
}
setCalendarPart(snapshot, []byte(fixtureCalData))
found := false
for _, child := range snapshot.Body.Children {
if strings.EqualFold(child.MediaType, calendarMediaType) {
found = true
}
}
if !found {
t.Error("text/calendar should be appended as fallback child")
}
}

View File

@@ -420,8 +420,9 @@ func (b Builder) HTMLBody(body []byte) Builder {
}
// CalendarBody sets the text/calendar body (e.g. for meeting invitations).
// May be combined with TextBody and/or HTMLBody; the resulting parts are wrapped
// in multipart/alternative.
// When combined with TextBody or HTMLBody, the calendar part is placed inside
// multipart/alternative alongside the body parts, matching the Feishu client
// convention for calendar invitation emails.
func (b Builder) CalendarBody(body []byte) Builder {
b.calendarBody = body
return b
@@ -731,6 +732,9 @@ func (b Builder) Build() ([]byte, error) {
// ── Body ───────────────────────────────────────────────────────────────────
// Full MIME hierarchy (outer layers only present when needed):
// multipart/mixed → multipart/related → multipart/alternative → body parts
//
// text/calendar lives inside multipart/alternative as an alternative
// representation of the message body, matching the Feishu client behavior.
if len(b.attachments) > 0 {
outerB := newBoundary()
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB)
@@ -809,27 +813,27 @@ func writePrimaryBody(buf *bytes.Buffer, b Builder) {
}
}
// writeAlternativeOrSingleBody writes the text body block.
// If multiple body types (text/plain, text/html, text/calendar) are present,
// they are wrapped in multipart/alternative. Otherwise a single part is written.
// writeAlternativeOrSingleBody writes the body block. When multiple content
// types coexist (text/plain, text/html, text/calendar), they are wrapped in
// multipart/alternative. text/calendar lives inside alternative as an
// alternative representation, matching the Feishu client behavior.
func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
hasText := len(b.textBody) > 0
hasHTML := len(b.htmlBody) > 0
hasCal := len(b.calendarBody) > 0
bodyCount := 0
partCount := 0
if hasText {
bodyCount++
partCount++
}
if hasHTML {
bodyCount++
partCount++
}
if hasCal {
bodyCount++
partCount++
}
switch {
case bodyCount > 1:
if partCount > 1 {
boundary := newBoundary()
writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary)
buf.WriteByte('\n')
@@ -840,15 +844,15 @@ func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
writeBodyPart(buf, boundary, "text/html", b.htmlBody)
}
if hasCal {
writeBodyPart(buf, boundary, "text/calendar", b.calendarBody)
fmt.Fprintf(buf, "--%s\n", boundary)
writeCalendarPart(buf, b.calendarBody)
}
fmt.Fprintf(buf, "--%s--\n", boundary)
case hasHTML:
} else if hasHTML {
writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody)
case hasCal:
writeSingleBodyPartHeaders(buf, "text/calendar", b.calendarBody)
default:
// text/plain (also handles empty body)
} else if hasCal {
writeCalendarPart(buf, b.calendarBody)
} else {
writeSingleBodyPartHeaders(buf, "text/plain", b.textBody)
}
}
@@ -992,6 +996,35 @@ func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) {
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
}
// writeCalendarPart writes the text/calendar MIME part. The method= parameter
// is derived from the METHOD property in the ICS body (defaulting to REQUEST
// when absent) so that passthrough ICS with METHOD:CANCEL or METHOD:REPLY
// produce a Content-Type that matches the body.
func writeCalendarPart(buf *bytes.Buffer, body []byte) {
method := extractICSMethod(body)
if method == "" {
method = "REQUEST"
}
cte := selectCTE(body)
fmt.Fprintf(buf, "Content-Type: text/calendar; method=%s; charset=UTF-8\n", method)
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
buf.WriteByte('\n')
}
// extractICSMethod scans the ICS body for the top-level METHOD property and
// returns its value (e.g. "REQUEST", "CANCEL", "REPLY"). Returns "" when the
// property is absent so callers can apply their own default.
func extractICSMethod(body []byte) string {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToUpper(line), "METHOD:") {
return strings.TrimSpace(line[7:])
}
}
return ""
}
// writeAttachmentPart writes a MIME attachment part.
// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045.
func writeAttachmentPart(buf *bytes.Buffer, att attachment) {

View File

@@ -678,6 +678,8 @@ func TestBuild_CalendarWithText(t *testing.T) {
}
eml := string(raw)
// text/calendar lives inside multipart/alternative as an alternative
// representation of the body, matching Feishu client behavior.
if !strings.Contains(eml, "multipart/alternative") {
t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml)
}
@@ -1359,3 +1361,35 @@ func TestHeaderValueTabAllowed(t *testing.T) {
t.Errorf("Header with tab in value: expected no error, got %v", err)
}
}
func TestWriteCalendarPart_MethodFromBody(t *testing.T) {
cases := []struct {
name string
ics string
wantCT string
}{
{"request", "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
{"cancel", "BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nEND:VCALENDAR\r\n", "method=CANCEL"},
{"reply", "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nEND:VCALENDAR\r\n", "method=REPLY"},
{"no method defaults to REQUEST", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eml, err := New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("Test").
Date(fixedDate).
MessageID("test-method@x").
HTMLBody([]byte("<p>hi</p>")).
CalendarBody([]byte(tc.ics)).
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
if !strings.Contains(string(eml), tc.wantCT) {
t.Errorf("expected Content-Type to contain %q\n%s", tc.wantCT, eml)
}
})
}
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/ics"
)
// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts
@@ -184,6 +185,15 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
"attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment",
"attachments[].is_inline": "true = inline image, false = regular attachment",
"attachments[].cid": "Content-ID for inline images (maps to <img src='cid:...'>)",
"calendar_event": "Parsed calendar invitation; present when the email contains a text/calendar part",
"calendar_event.method": "iTIP method, e.g. REQUEST, CANCEL, REPLY",
"calendar_event.uid": "Globally unique event identifier (UID property)",
"calendar_event.summary": "Event title (SUMMARY property)",
"calendar_event.start": "Event start time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.end": "Event end time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.location": "Event location string; omitted when not set",
"calendar_event.organizer": "Organizer email address",
"calendar_event.attendees": "List of attendee email addresses",
},
"thread_extra_fields": map[string]string{
"thread_id": "Thread ID",
@@ -1199,11 +1209,23 @@ type normalizedMessageForCompose struct {
BodyPlainText string `json:"body_plain_text"`
BodyPreview string `json:"body_preview"`
BodyHTML string `json:"body_html,omitempty"`
CalendarEvent *calendarEventOutput `json:"calendar_event,omitempty"`
Attachments []mailAttachmentOutput `json:"attachments"`
Images []mailImageOutput `json:"images"`
Warnings []warningEntry `json:"warnings,omitempty"`
}
type calendarEventOutput struct {
Method string `json:"method,omitempty"`
UID string `json:"uid,omitempty"`
Summary string `json:"summary,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Location string `json:"location,omitempty"`
Organizer string `json:"organizer,omitempty"`
Attendees []string `json:"attendees,omitempty"`
}
// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20.
// List params are embedded directly in the URL (SDK workaround for repeated query params).
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
@@ -1349,6 +1371,9 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
if html && normalized.BodyHTML != "" {
out["body_html"] = normalized.BodyHTML
}
if normalized.CalendarEvent != nil {
out["calendar_event"] = normalized.CalendarEvent
}
out["attachments"] = buildPublicAttachments(msg)
return out
@@ -1458,6 +1483,29 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
out.BodyHTML = decodeBase64URL(strVal(msg["body_html"]))
}
// Calendar event
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
if parsed := ics.ParseEvent(decoded); parsed != nil {
ce := &calendarEventOutput{
Method: parsed.Method,
UID: parsed.UID,
Summary: parsed.Summary,
Location: parsed.Location,
Organizer: parsed.Organizer,
Attendees: parsed.Attendees,
}
if !parsed.Start.IsZero() {
ce.Start = parsed.Start.UTC().Format(time.RFC3339)
}
if !parsed.End.IsZero() {
ce.End = parsed.End.UTC().Format(time.RFC3339)
}
out.CalendarEvent = ce
}
}
}
// Attachments
attachments := make([]mailAttachmentOutput, 0)
images := make([]mailImageOutput, 0)
@@ -1568,6 +1616,7 @@ type composeSourceMessage struct {
ForwardAttachments []forwardSourceAttachment
InlineImages []inlineSourcePart
FailedAttachmentIDs map[string]bool
OriginalCalendarICS []byte // raw ICS bytes from body_calendar (for forward passthrough)
}
// fetchComposeSourceMessage loads a message via the +message pipeline and converts it
@@ -1577,6 +1626,12 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
if err != nil {
return composeSourceMessage{}, err
}
var originalCalICS []byte
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
originalCalICS = []byte(decoded)
}
}
attIDs := extractAttachmentIDs(msg)
urlMap, warnings := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs)
failedIDs := make(map[string]bool)
@@ -1592,6 +1647,7 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
ForwardAttachments: toForwardSourceAttachments(out),
InlineImages: toInlineSourceParts(out),
FailedAttachmentIDs: failedIDs,
OriginalCalendarICS: originalCalICS,
}, nil
}
@@ -2252,6 +2308,21 @@ func inlineSpecFilePaths(specs []InlineSpec) []string {
return paths
}
// validateEventSendTimeExclusion checks that --send-time and --event-* are not
// used together. This is enforced here (in Validate, before Execute) because the
// Shortcut framework does not expose a cobra-level hook for MarkFlagsMutuallyExclusive.
func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
if runtime.Str("send-time") == "" {
return nil
}
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
if runtime.Str(f) != "" {
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
}
}
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 {
@@ -2391,3 +2462,143 @@ func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFl
}
return nil
}
// buildCalendarBodyFromArgs builds ICS from explicit string arguments (for draft-edit).
// Callers are expected to have pre-validated startStr/endStr via parseEventTimeRange;
// parse errors are silently ignored here and produce a zero-time DTSTART/DTEND.
func buildCalendarBodyFromArgs(summary, startStr, endStr, location, senderEmail, toAddrs, ccAddrs string) []byte {
if summary == "" {
return nil
}
start, _ := parseISO8601(startStr)
end, _ := parseISO8601(endStr)
var attendees []ics.Address
for _, addr := range parseNetAddrs(toAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
for _, addr := range parseNetAddrs(ccAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
return ics.Build(ics.Event{
Summary: summary,
Location: location,
Start: start,
End: end,
Organizer: ics.Address{Email: senderEmail},
Attendees: attendees,
})
}
// joinAddresses joins draft Address list into comma-separated string.
func joinAddresses(addrs []draftpkg.Address) string {
if len(addrs) == 0 {
return ""
}
parts := make([]string, len(addrs))
for i, a := range addrs {
parts[i] = a.Address
}
return strings.Join(parts, ",")
}
// Calendar event flag definitions, shared by all compose shortcuts.
// Declared as individual vars (like priorityFlag and signatureFlag) so
// callers can list them explicitly in their Flags slice without relying
// on slice-index access.
var (
eventSummaryFlag = common.Flag{Name: "event-summary", Desc: "Calendar event title. Setting this enables calendar invitation mode."}
eventStartFlag = common.Flag{Name: "event-start", Desc: "Event start time (ISO 8601, e.g. 2026-04-20T14:00+08:00). Required when --event-summary is set."}
eventEndFlag = common.Flag{Name: "event-end", Desc: "Event end time (ISO 8601). Required when --event-summary is set."}
eventLocationFlag = common.Flag{Name: "event-location", Desc: "Event location (optional)."}
)
// validateEventFlags checks that --event-summary, --event-start, --event-end are either all set or all empty.
func validateEventFlags(runtime *common.RuntimeContext) error {
summary := runtime.Str("event-summary")
start := runtime.Str("event-start")
end := runtime.Str("event-end")
location := runtime.Str("event-location")
hasAny := summary != "" || start != "" || end != "" || location != ""
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return prefixEventRangeError("--event-", err)
}
return nil
}
// parseEventTimeRange parses start/end ISO 8601 strings and verifies that
// end is strictly after start. Shared by validateEventFlags (compose path)
// and buildDraftEditPatch (draft-edit path) so the rules stay in one place.
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
startT, err := parseISO8601(start)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
}
endT, err := parseISO8601(end)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
}
if !endT.After(startT) {
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
}
return startT, endT, nil
}
// prefixEventRangeError rewrites parseEventTimeRange's "start:" / "end:"
// error with the caller's flag-name prefix so users see the exact flag
// that caused the failure.
func prefixEventRangeError(flagPrefix string, err error) error {
msg := err.Error()
switch {
case strings.HasPrefix(msg, "start: "):
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
case strings.HasPrefix(msg, "end: "):
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
default:
return err
}
}
// parseISO8601 parses common ISO 8601 time formats.
func parseISO8601(s string) (time.Time, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
// Returns nil if --event-summary is not set.
func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAddrs, ccAddrs string) []byte {
return buildCalendarBodyFromArgs(
runtime.Str("event-summary"),
runtime.Str("event-start"),
runtime.Str("event-end"),
runtime.Str("event-location"),
senderEmail, toAddrs, ccAddrs,
)
}

View File

@@ -1085,7 +1085,39 @@ func TestValidateSendTime_Valid(t *testing.T) {
}
}
// TestParsePriority verifies parse priority.
func TestValidateEventSendTimeExclusion(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
cases := []struct {
name string
eventFlag string
eventVal string
}{
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
{"event-location triggers exclusion", "event-location", "Room 5F"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("send-time", "", "")
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
cmd.Flags().String(f, "", "")
}
_ = cmd.Flags().Set("send-time", future)
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
rt := &common.RuntimeContext{Cmd: cmd}
err := validateEventSendTimeExclusion(rt)
if err == nil {
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
}
if !strings.Contains(err.Error(), "--event-*") {
t.Errorf("expected error to mention --event-*, got: %v", err)
}
})
}
}
func TestParsePriority(t *testing.T) {
cases := []struct {
name string
@@ -1334,7 +1366,6 @@ func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.Runtime
return &common.RuntimeContext{Cmd: cmd}
}
// TestRequireSenderForRequestReceipt verifies require sender for request receipt.
func TestRequireSenderForRequestReceipt(t *testing.T) {
cases := []struct {
name string
@@ -1365,7 +1396,6 @@ func TestRequireSenderForRequestReceipt(t *testing.T) {
}
}
// TestShellQuoteForHint verifies shell quote for hint.
func TestShellQuoteForHint(t *testing.T) {
cases := []struct {
name string
@@ -1391,7 +1421,6 @@ func TestShellQuoteForHint(t *testing.T) {
}
}
// TestSanitizeForSingleLine verifies sanitize for single line.
func TestSanitizeForSingleLine(t *testing.T) {
cases := []struct {
name string
@@ -1415,7 +1444,6 @@ func TestSanitizeForSingleLine(t *testing.T) {
}
}
// TestValidateHeaderAddress verifies validate header address.
func TestValidateHeaderAddress(t *testing.T) {
cases := []struct {
name string
@@ -1447,3 +1475,199 @@ func TestValidateHeaderAddress(t *testing.T) {
})
}
}
// ---------------------------------------------------------------------------
// parseEventTimeRange
// ---------------------------------------------------------------------------
func TestParseEventTimeRange_OK(t *testing.T) {
s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !e.After(s) {
t.Errorf("end should be after start; got start=%v end=%v", s, e)
}
}
func TestParseEventTimeRange_EndBeforeStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end < start")
}
if !strings.Contains(err.Error(), "end time must be after start time") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestParseEventTimeRange_EndEqualsStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end == start (zero duration)")
}
}
func TestParseEventTimeRange_InvalidStart(t *testing.T) {
_, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00")
if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") {
t.Errorf("expected start parse error, got: %v", err)
}
}
func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time")
if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") {
t.Errorf("expected end parse error, got: %v", err)
}
}
func TestPrefixEventRangeError(t *testing.T) {
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
// Non-prefixed error passes through unchanged.
other := fmt.Errorf("end time must be after start time")
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
t.Errorf("got %q", got)
}
}
// ---------------------------------------------------------------------------
// validateEventFlags (runtime-backed)
// ---------------------------------------------------------------------------
func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("event-summary", "", "")
cmd.Flags().String("event-start", "", "")
cmd.Flags().String("event-end", "", "")
cmd.Flags().String("event-location", "", "")
if summary != "" {
_ = cmd.Flags().Set("event-summary", summary)
}
if start != "" {
_ = cmd.Flags().Set("event-start", start)
}
if end != "" {
_ = cmd.Flags().Set("event-end", end)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestValidateEventFlags_AllEmptyOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "", "", "")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_AllSetOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_PartialRejected(t *testing.T) {
cases := []struct {
name string
summary string
start string
end string
}{
{"only_summary", "Meeting", "", ""},
{"only_start", "", "2026-04-25T10:00+08:00", ""},
{"only_end", "", "", "2026-04-25T11:00+08:00"},
{"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end)
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "must all be provided together") {
t.Errorf("expected 'all together' error, got %v", err)
}
})
}
}
func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "after start") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "--event-start") {
t.Errorf("expected --event-start error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// buildCalendarBodyFromArgs
// ---------------------------------------------------------------------------
func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) {
got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "")
if got != nil {
t.Errorf("expected nil for empty summary, got %d bytes", len(got))
}
}
func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Product Review",
"2026-04-25T14:00+08:00",
"2026-04-25T15:00+08:00",
"5F Room",
"sender@example.com",
"a@example.com,b@example.com",
"c@example.com",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
s := string(got)
checks := []string{
"BEGIN:VCALENDAR",
"SUMMARY:Product Review",
"LOCATION:5F Room",
"sender@example.com",
"a@example.com",
"b@example.com",
"c@example.com",
}
for _, want := range checks {
if !strings.Contains(s, want) {
t.Errorf("missing %q in generated ICS:\n%s", want, s)
}
}
}
func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Meeting",
"2026-04-25T10:00+08:00",
"2026-04-25T11:00+08:00",
"",
"sender@example.com",
"to@example.com",
"",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
if !strings.Contains(string(got), "to@example.com") {
t.Error("attendee missing")
}
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package ics provides RFC 5545 iCalendar generation and parsing for mail calendar invitations.
package ics
import (
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
// Event holds the data needed to generate an ICS VCALENDAR invitation.
type Event struct {
UID string // auto-generated if empty
Summary string // SUMMARY (required)
Location string // LOCATION (optional)
Start time.Time // DTSTART (required)
End time.Time // DTEND (required)
Organizer Address // ORGANIZER
Attendees []Address // ATTENDEE list (To + Cc, excluding Bcc)
}
// Address represents a name + email pair for ORGANIZER / ATTENDEE.
type Address struct {
Name string
Email string
}
// Build generates a RFC 5545 VCALENDAR byte slice with METHOD:REQUEST.
// The output is suitable for use as a text/calendar MIME part.
func Build(event Event) []byte {
uid := event.UID
if uid == "" {
uid = uuid.New().String()
}
now := time.Now().UTC()
nowICS := formatICSTime(now)
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("CALSCALE:GREGORIAN\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//Lark CLI//EN\r\n")
b.WriteString("METHOD:REQUEST\r\n")
b.WriteString("X-LARK-MAIL-DRAFT:TRUE\r\n")
b.WriteString("BEGIN:VEVENT\r\n")
writeFolded(&b, "UID", uid)
writeFolded(&b, "DTSTAMP", nowICS)
writeFolded(&b, "CREATED", nowICS)
writeFolded(&b, "LAST-MODIFIED", nowICS)
writeFolded(&b, "DTSTART", formatICSTime(event.Start.UTC()))
writeFolded(&b, "DTEND", formatICSTime(event.End.UTC()))
writeFolded(&b, "SUMMARY", escapeTextValue(event.Summary))
if event.Location != "" {
writeFolded(&b, "LOCATION", escapeTextValue(event.Location))
}
b.WriteString("STATUS:CONFIRMED\r\n")
b.WriteString("TRANSP:OPAQUE\r\n")
b.WriteString("SEQUENCE:0\r\n")
if event.Organizer.Email != "" {
organizer := "ORGANIZER;ROLE=CHAIR"
if event.Organizer.Name != "" {
organizer += ";CN=" + quoteCNParam(event.Organizer.Name)
} else {
organizer += ";CN=" + quoteCNParam(event.Organizer.Email)
}
writeFolded(&b, organizer, mailtoScheme+sanitizeMailtoAddress(event.Organizer.Email))
}
for _, a := range event.Attendees {
attendee := "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL"
if a.Name != "" {
attendee += ";CN=" + quoteCNParam(a.Name)
} else {
attendee += ";CN=" + quoteCNParam(a.Email)
}
attendee += ";PARTSTAT=NEEDS-ACTION"
writeFolded(&b, attendee, mailtoScheme+sanitizeMailtoAddress(a.Email))
}
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
return []byte(b.String())
}
// formatICSTime formats a time.Time as ICS UTC: YYYYMMDDTHHMMSSZ.
func formatICSTime(t time.Time) string {
return t.Format("20060102T150405Z")
}
// escapeTextValue escapes a string for use as an ICS TEXT value per RFC 5545
// §3.3.11: backslash, newline, semicolon, and comma carry structural meaning
// and must be escaped. Applied to SUMMARY, LOCATION, DESCRIPTION etc. — not
// to identifiers (UID), date-times (DTSTART/DTEND), or URIs.
//
// Without this, a user-supplied summary containing a newline or colon would
// let the payload inject a fake property line, e.g.
//
// --event-summary "foo\nDTSTART:20000101T000000Z"
//
// would turn into a second DTSTART line after folding.
func escapeTextValue(s string) string {
// Normalise CR / CRLF so downstream only sees LF.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
// Order matters: escape backslash first so its own replacement is not
// picked up by later rules.
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, ";", `\;`)
s = strings.ReplaceAll(s, ",", `\,`)
return s
}
// quoteCNParam wraps a CN parameter value in double-quotes per RFC 5545 §3.2
// when the value contains characters that are not allowed in an unquoted
// paramtext (, ; :). Characters that are illegal inside a quoted-string are
// stripped: DQUOTE (%x22) is excluded by QSAFE-CHAR, and control characters
// (%x00%x08, %x0A%x1F, %x7F) would break the property line structure.
func quoteCNParam(s string) string {
s = strings.Map(func(r rune) rune {
if r == '"' || r < 0x09 || (r >= 0x0A && r <= 0x1F) || r == 0x7F {
return -1
}
return r
}, s)
if strings.ContainsAny(s, ",:;") {
return `"` + s + `"`
}
return s
}
// writeFolded writes a property line with RFC 5545 line folding (75-octet limit).
// Long lines are folded by inserting CRLF + space at UTF-8 character boundaries.
// Continuation lines begin with a single SPACE (1 octet), so their content is
// limited to 74 octets to keep the total physical line at ≤ 75 octets.
func writeFolded(b *strings.Builder, name, value string) {
line := fmt.Sprintf("%s:%s", name, value)
const maxLineOctets = 75 // RFC 5545 §3.1: lines SHOULD NOT be longer than 75 octets
limit := maxLineOctets
for len(line) > limit {
// Find the last complete UTF-8 character that fits within the limit.
cut := 0
for i := 0; i < len(line); {
_, size := utf8.DecodeRuneInString(line[i:])
if i+size > limit {
break
}
i += size
cut = i
}
if cut == 0 {
// Single character exceeds limit (shouldn't happen in practice).
cut = limit
}
b.WriteString(line[:cut])
b.WriteString("\r\n ")
line = line[cut:]
limit = maxLineOctets - 1 // continuation lines: 1-octet SPACE + 74 content = 75
}
b.WriteString(line)
b.WriteString("\r\n")
}
// sanitizeMailtoAddress strips control characters (CR, LF, and other chars
// below 0x20 or equal to 0x7F) from an email address before embedding it in a
// MAILTO: URI value. Prevents property-injection attacks analogous to the CN
// parameter protection in quoteCNParam.
func sanitizeMailtoAddress(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7F {
return -1
}
return r
}, s)
}

View File

@@ -0,0 +1,719 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package ics
import (
"strings"
"testing"
"time"
)
func TestBuild_Basic(t *testing.T) {
event := Event{
UID: "test-uid-123",
Summary: "Product Review",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
Attendees: []Address{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
},
}
// Unfold before assertion so long property lines (which exceed 75 octets and
// are folded per RFC 5545) can be matched as a single contiguous string.
ics := unfoldLines(string(Build(event)))
checks := []string{
"BEGIN:VCALENDAR",
"CALSCALE:GREGORIAN",
"VERSION:2.0",
"METHOD:REQUEST",
"X-LARK-MAIL-DRAFT:TRUE",
"BEGIN:VEVENT",
"UID:test-uid-123",
"DTSTAMP:",
"CREATED:",
"LAST-MODIFIED:",
"DTSTART:20260420T060000Z",
"DTEND:20260420T070000Z",
"SUMMARY:Product Review",
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"SEQUENCE:0",
"ORGANIZER;ROLE=CHAIR;CN=Sender:MAILTO:sender@example.com",
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Alice;PARTSTAT=NEEDS-ACTION:MAILTO:alice@example.com",
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Bob;PARTSTAT=NEEDS-ACTION:MAILTO:bob@example.com",
"END:VEVENT",
"END:VCALENDAR",
}
for _, want := range checks {
if !strings.Contains(ics, want) {
t.Errorf("missing %q in ICS:\n%s", want, ics)
}
}
}
func TestBuild_OrganizerFallsBackToEmailWhenNoName(t *testing.T) {
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Email: "o@e.com"},
Attendees: []Address{{Email: "a@e.com"}},
}
ics := unfoldLines(string(Build(event)))
if !strings.Contains(ics, "ORGANIZER;ROLE=CHAIR;CN=o@e.com:MAILTO:o@e.com") {
t.Errorf("ORGANIZER without name should fall back to email as CN:\n%s", ics)
}
if !strings.Contains(ics, "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=a@e.com;PARTSTAT=NEEDS-ACTION:MAILTO:a@e.com") {
t.Errorf("ATTENDEE without name should fall back to email as CN:\n%s", ics)
}
}
func TestBuild_WithLocation(t *testing.T) {
event := Event{
Summary: "Meeting",
Location: "5F Conference Room",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
if !strings.Contains(ics, "LOCATION:5F Conference Room") {
t.Errorf("missing LOCATION in ICS:\n%s", ics)
}
}
func TestBuild_NoLocation(t *testing.T) {
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
if strings.Contains(ics, "LOCATION") {
t.Errorf("should not have LOCATION when empty:\n%s", ics)
}
}
func TestBuild_AutoUIDIsPureUUID(t *testing.T) {
event := Event{
Summary: "Test",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
if !strings.Contains(ics, "UID:") {
t.Fatal("missing UID")
}
// Extract the UID line to assert on its format.
var uid string
for _, line := range strings.Split(ics, "\r\n") {
if strings.HasPrefix(line, "UID:") {
uid = strings.TrimPrefix(line, "UID:")
break
}
}
if strings.Contains(uid, "@") {
t.Errorf("auto-generated UID should be pure UUID (no @host suffix), got %q", uid)
}
// UUID v4 has 36 chars (8-4-4-4-12 plus 4 dashes).
if len(uid) != 36 {
t.Errorf("auto-generated UID should be 36-char UUID, got %d chars: %q", len(uid), uid)
}
}
func TestBuild_EscapesTextValues(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"semicolon", "a;b", `a\;b`},
{"comma", "a,b", `a\,b`},
{"backslash", `a\b`, `a\\b`},
{"newline", "a\nb", `a\nb`},
{"crlf", "a\r\nb", `a\nb`},
{"mixed", `a;\,b` + "\n", `a\;\\\,b\n`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := escapeTextValue(tc.input); got != tc.want {
t.Errorf("escapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
// TestBuild_RejectsInjectionViaSummary proves that a malicious SUMMARY
// containing a newline plus a fake property line cannot inject a second
// DTSTART into the rendered ICS — the newline is escaped into a literal
// "\n" sequence inside the SUMMARY value.
func TestBuild_RejectsInjectionViaSummary(t *testing.T) {
event := Event{
Summary: "harmless\nDTSTART:19700101T000000Z",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := unfoldLines(string(Build(event)))
// Count occurrences of DTSTART at the start of a line (i.e., as an
// actual property), ignoring the literal "DTSTART:" substring that
// now appears inside the escaped SUMMARY value.
dtstartPropertyLines := 0
for _, line := range strings.Split(ics, "\r\n") {
if strings.HasPrefix(line, "DTSTART:") {
dtstartPropertyLines++
}
}
if dtstartPropertyLines != 1 {
t.Errorf("expected exactly one DTSTART: property line, got %d in:\n%s", dtstartPropertyLines, ics)
}
if !strings.Contains(ics, `SUMMARY:harmless\nDTSTART:19700101T000000Z`) {
t.Errorf("expected escaped SUMMARY to contain literal \\n, got:\n%s", ics)
}
}
func TestBuild_CNWithSpecialCharsIsQuoted(t *testing.T) {
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Smith, Alice", Email: "alice@example.com"},
Attendees: []Address{
{Name: "Doe; Bob", Email: "bob@example.com"},
{Name: "Plain Name", Email: "plain@example.com"},
},
}
ics := unfoldLines(string(Build(event)))
if !strings.Contains(ics, `CN="Smith, Alice"`) {
t.Errorf("expected quoted CN for organizer name with comma:\n%s", ics)
}
if !strings.Contains(ics, `CN="Doe; Bob"`) {
t.Errorf("expected quoted CN for attendee name with semicolon:\n%s", ics)
}
// Names without special chars must NOT be double-quoted.
if !strings.Contains(ics, "CN=Plain Name") || strings.Contains(ics, `CN="Plain Name"`) {
t.Errorf("plain name should be unquoted:\n%s", ics)
}
}
func TestBuild_EmailAddressSanitized(t *testing.T) {
// CR/LF inside an email address must not produce injected property lines.
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Alice", Email: "alice@example.com\r\nX-INJECTED:bad"},
Attendees: []Address{{Name: "Bob", Email: "bob@example.com\nY-INJECTED:bad"}},
}
output := string(Build(event))
if strings.Contains(output, "\r\nX-INJECTED") {
t.Error("organizer email CR/LF injection not sanitized")
}
if strings.Contains(output, "\r\nY-INJECTED") {
t.Error("attendee email CR/LF injection not sanitized")
}
}
func TestBuild_CNStripsControlChars(t *testing.T) {
// A display name containing CR, LF, or other control characters must not
// produce extra ICS property lines (injection via CN parameter).
event := Event{
Summary: "Meeting",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Alice\r\nDTSTART:99999999", Email: "alice@example.com"},
Attendees: []Address{
{Name: "Bob\nX-INJECTED:bad", Email: "bob@example.com"},
},
}
output := string(Build(event))
// Check that control chars don't produce injected property lines.
// A standalone ICS property line starts at the beginning of a CRLF-delimited line.
if strings.Contains(output, "\r\nDTSTART:99999999") {
t.Error("ICS output contains injected DTSTART property line via organizer CN")
}
if strings.Contains(output, "\r\nX-INJECTED") {
t.Error("ICS output contains injected X-INJECTED property line via attendee CN")
}
}
func TestBuild_LineFolding(t *testing.T) {
event := Event{
Summary: strings.Repeat("A", 100), // long summary triggers folding
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
ics := string(Build(event))
// Every physical line (first line and continuation lines alike) must be
// ≤ 75 octets excluding the CRLF terminator per RFC 5545 §3.1.
for _, line := range strings.Split(ics, "\r\n") {
if len(line) > 75 {
t.Errorf("line exceeds 75 octets: %q (len=%d)", line, len(line))
}
}
}
func TestParseEvent_Basic(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"METHOD:REQUEST\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:abc123@larksuite.com\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Product Review\r\n" +
"LOCATION:5F Room\r\n" +
"ORGANIZER;CN=Sender:mailto:sender@example.com\r\n" +
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Alice:mailto:alice@example.com\r\n" +
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Bob:mailto:bob@example.com\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.Method != "REQUEST" {
t.Errorf("Method = %q, want REQUEST", event.Method)
}
if event.IsLarkDraft {
t.Error("IsLarkDraft = true, want false (no X-LARK-MAIL-DRAFT in input)")
}
if event.UID != "abc123@larksuite.com" {
t.Errorf("UID = %q, want abc123@larksuite.com", event.UID)
}
if event.Summary != "Product Review" {
t.Errorf("Summary = %q, want Product Review", event.Summary)
}
if event.Location != "5F Room" {
t.Errorf("Location = %q, want 5F Room", event.Location)
}
if event.Organizer != "sender@example.com" {
t.Errorf("Organizer = %q, want sender@example.com", event.Organizer)
}
if len(event.Attendees) != 2 {
t.Fatalf("Attendees count = %d, want 2", len(event.Attendees))
}
if event.Attendees[0] != "alice@example.com" {
t.Errorf("Attendees[0] = %q, want alice@example.com", event.Attendees[0])
}
if event.Attendees[1] != "bob@example.com" {
t.Errorf("Attendees[1] = %q, want bob@example.com", event.Attendees[1])
}
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !event.Start.Equal(wantStart) {
t.Errorf("Start = %v, want %v", event.Start, wantStart)
}
wantEnd := time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC)
if !event.End.Equal(wantEnd) {
t.Errorf("End = %v, want %v", event.End, wantEnd)
}
}
func TestParseEvent_IsLarkDraft(t *testing.T) {
icsWithMarker := "BEGIN:VCALENDAR\r\n" +
"METHOD:REQUEST\r\n" +
"X-LARK-MAIL-DRAFT:TRUE\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:draft-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Draft Event\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(icsWithMarker)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if !event.IsLarkDraft {
t.Error("IsLarkDraft = false, want true")
}
icsWithoutMarker := "BEGIN:VCALENDAR\r\n" +
"METHOD:REQUEST\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:external-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:External Event\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event2 := ParseEvent(icsWithoutMarker)
if event2 == nil {
t.Fatal("ParseEvent returned nil")
}
if event2.IsLarkDraft {
t.Error("IsLarkDraft = true, want false")
}
}
func TestParseEvent_WithTZID(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:tz-test\r\n" +
"DTSTART;TZID=Asia/Shanghai:20260420T140000\r\n" +
"DTEND;TZID=Asia/Shanghai:20260420T150000\r\n" +
"SUMMARY:TZ Test\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
// 14:00 Asia/Shanghai = 06:00 UTC
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !event.Start.Equal(wantStart) {
t.Errorf("Start = %v, want %v", event.Start, wantStart)
}
}
func TestParseEvent_FoldedLines(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:fold-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:This is a very long summary that should be unfolded correctly by th\r\n" +
" e parser when processing\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
want := "This is a very long summary that should be unfolded correctly by the parser when processing"
if event.Summary != want {
t.Errorf("Summary = %q, want %q", event.Summary, want)
}
}
func TestParseEvent_FoldedLines_LFOnly(t *testing.T) {
// Some mail servers strip \r before storage, producing LF-only ICS.
ics := "BEGIN:VCALENDAR\n" +
"BEGIN:VEVENT\n" +
"UID:lf-fold-test\n" +
"DTSTART:20260420T060000Z\n" +
"DTEND:20260420T070000Z\n" +
"SUMMARY:This is a very long summary that should be unfolded correctly by th\n" +
" e parser when LF-only folding is used\n" +
"END:VEVENT\n" +
"END:VCALENDAR\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil for LF-only ICS")
}
want := "This is a very long summary that should be unfolded correctly by the parser when LF-only folding is used"
if event.Summary != want {
t.Errorf("Summary = %q, want %q", event.Summary, want)
}
}
func TestParseEvent_NoVEvent(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
event := ParseEvent(ics)
if event != nil {
t.Error("expected nil for ICS without VEVENT")
}
}
// TestParseEvent_OrganizerWithoutMailto covers the case where the backend
// re-serializes our ICS and drops the "MAILTO:" scheme prefix. Observed in
// practice on drafts returned by user_mailboxes/me/drafts.get.
func TestParseEvent_OrganizerWithoutMailto(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:no-mailto-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Test\r\n" +
"ORGANIZER;CN=org@example.com:org@example.com\r\n" +
"ATTENDEE;PARTSTAT=NEEDS-ACTION;CN=att@example.com:att@example.com\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.Organizer != "org@example.com" {
t.Errorf("Organizer = %q, want org@example.com (parser must accept bare email when mailto: is absent)", event.Organizer)
}
if len(event.Attendees) != 1 || event.Attendees[0] != "att@example.com" {
t.Errorf("Attendees = %v, want [att@example.com]", event.Attendees)
}
}
func TestParseEvent_MailtoCaseInsensitive(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:case-test\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Test\r\n" +
"ORGANIZER;CN=Sender:MAILTO:sender@example.com\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.Organizer != "sender@example.com" {
t.Errorf("Organizer = %q, want sender@example.com (uppercase MAILTO: should be accepted)", event.Organizer)
}
}
func TestParseEvent_RecurrenceIDPopulatesOriginalTime(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:recurring-exception\r\n" +
"DTSTART:20260501T020000Z\r\n" +
"DTEND:20260501T030000Z\r\n" +
"RECURRENCE-ID:20260501T020000Z\r\n" +
"SUMMARY:Exception instance\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
// 2026-05-01 02:00:00 UTC = 1777600800
if event.OriginalTime != 1777600800 {
t.Errorf("OriginalTime = %d, want 1777600800", event.OriginalTime)
}
}
func TestParseEvent_NoRecurrenceIDYieldsZero(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:single-event\r\n" +
"DTSTART:20260420T060000Z\r\n" +
"DTEND:20260420T070000Z\r\n" +
"SUMMARY:Single\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
event := ParseEvent(ics)
if event == nil {
t.Fatal("ParseEvent returned nil")
}
if event.OriginalTime != 0 {
t.Errorf("OriginalTime = %d, want 0 for non-recurring event", event.OriginalTime)
}
}
func TestUnescapeTextValue(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"plain", "hello", "hello"},
{"semicolon", `a\;b`, "a;b"},
{"comma", `a\,b`, "a,b"},
{"backslash", `a\\b`, `a\b`},
{"newline_lower", `a\nb`, "a\nb"},
{"newline_upper", `a\Nb`, "a\nb"},
{"mixed", `a\;\\\,b\n`, "a;\\,b\n"},
{"dangling_backslash_kept", `ends\`, `ends\`},
{"unknown_escape_kept", `\x`, `\x`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := unescapeTextValue(tc.input); got != tc.want {
t.Errorf("unescapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestRoundTrip_SpecialCharsInSummaryAndLocation(t *testing.T) {
event := Event{
UID: "rt-special",
Summary: `Review;with,special\chars` + "\n" + `and newline`,
Location: `B1,Room 3;floor 2`,
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
}
parsed := ParseEvent(string(Build(event)))
if parsed == nil {
t.Fatal("ParseEvent returned nil")
}
if !parsed.IsLarkDraft {
t.Error("IsLarkDraft = false after roundtrip, want true (Build should write X-LARK-MAIL-DRAFT)")
}
if parsed.Summary != event.Summary {
t.Errorf("Summary roundtrip: got %q, want %q", parsed.Summary, event.Summary)
}
if parsed.Location != event.Location {
t.Errorf("Location roundtrip: got %q, want %q", parsed.Location, event.Location)
}
}
func TestBuild_WriteFolded_SingleCharExceeds75Bytes(t *testing.T) {
// A single multibyte rune that is > 75 bytes is not reachable in practice,
// but we exercise the cut==0 fallback by constructing a fake line via a
// 75-octet name followed by a multi-octet rune that crosses the boundary.
// The simplest way: a name of exactly 74 chars + ':' = 75, then a multi-byte
// rune — the first iteration has cut==0, triggering the fallback.
var b strings.Builder
longName := strings.Repeat("A", 74)
// value starts with a 3-byte UTF-8 rune (€ = 0xE2 0x82 0xAC)
writeFolded(&b, longName, "€remainder")
result := b.String()
if !strings.Contains(result, "\r\n ") {
t.Errorf("expected line folding CRLF+SP in output:\n%q", result)
}
}
func TestSplitProperty_NoColon(t *testing.T) {
name, value := splitProperty("NOCOLON")
if name != "NOCOLON" || value != "" {
t.Errorf("splitProperty(no colon): got name=%q value=%q, want NOCOLON/\"\"", name, value)
}
}
func TestSplitProperty_QuotedColon(t *testing.T) {
// A colon inside a quoted CN param must not be treated as the separator.
name, value := splitProperty(`ORGANIZER;CN="Doe: Jane":mailto:alice@example.com`)
if name != `ORGANIZER;CN="Doe: Jane"` {
t.Errorf("name = %q, want ORGANIZER;CN=\"Doe: Jane\"", name)
}
if value != "mailto:alice@example.com" {
t.Errorf("value = %q, want mailto:alice@example.com", value)
}
}
func TestParseICSTime_TZIDCaseInsensitive(t *testing.T) {
// TZID parameter name is case-insensitive per RFC 5545 §3.2.
result := parseICSTime("20260420T140000", "DTSTART;tzid=Asia/Shanghai")
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime with lowercase tzid= = %v, want %v", result, want)
}
}
func TestParseICSTime_TZIDWithTrailingParam(t *testing.T) {
// Trailing parameters after TZID (e.g. ;VALUE=DATE-TIME) must not be
// included in the timezone name passed to time.LoadLocation.
result := parseICSTime("20260420T140000", "DTSTART;TZID=Asia/Shanghai;VALUE=DATE-TIME")
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime with trailing ;VALUE= = %v, want %v", result, want)
}
}
func TestParseICSTime_DateOnly(t *testing.T) {
// All-day event: YYYYMMDD format
result := parseICSTime("20260420", "DTSTART;VALUE=DATE")
want := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime date-only = %v, want %v", result, want)
}
}
func TestParseICSTime_LocalWithoutTZ(t *testing.T) {
// Local time without timezone suffix (no Z, no TZID) — treated as UTC
result := parseICSTime("20260420T140000", "DTSTART")
want := time.Date(2026, 4, 20, 14, 0, 0, 0, time.UTC)
if !result.Equal(want) {
t.Errorf("parseICSTime local = %v, want %v", result, want)
}
}
func TestParseICSTime_InvalidReturnsZero(t *testing.T) {
result := parseICSTime("not-a-date", "DTSTART")
if !result.IsZero() {
t.Errorf("parseICSTime invalid = %v, want zero", result)
}
}
func TestExtractMailto_NoAt(t *testing.T) {
result := extractMailto("notanemail")
if result != "" {
t.Errorf("extractMailto(no @) = %q, want empty", result)
}
}
func TestRoundTrip(t *testing.T) {
original := Event{
UID: "roundtrip-test",
Summary: "Roundtrip Meeting",
Location: "Room 301",
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
Attendees: []Address{
{Name: "Alice", Email: "alice@example.com"},
},
}
icsBytes := Build(original)
parsed := ParseEvent(string(icsBytes))
if parsed == nil {
t.Fatal("ParseEvent returned nil on Build output")
}
if parsed.UID != original.UID {
t.Errorf("UID roundtrip: %q != %q", parsed.UID, original.UID)
}
if parsed.Summary != original.Summary {
t.Errorf("Summary roundtrip: %q != %q", parsed.Summary, original.Summary)
}
if parsed.Location != original.Location {
t.Errorf("Location roundtrip: %q != %q", parsed.Location, original.Location)
}
if !parsed.Start.Equal(original.Start) {
t.Errorf("Start roundtrip: %v != %v", parsed.Start, original.Start)
}
if parsed.Organizer != original.Organizer.Email {
t.Errorf("Organizer roundtrip: %q != %q", parsed.Organizer, original.Organizer.Email)
}
if len(parsed.Attendees) != 1 || parsed.Attendees[0] != "alice@example.com" {
t.Errorf("Attendees roundtrip: %v", parsed.Attendees)
}
}
func TestParseEvent_LowercaseAndParameterizedProps(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
"uid:lowercased-uid-value\r\n" +
"SUMMARY;LANGUAGE=en-US:Team Sync\r\n" +
"location;ALTREP=\"cid:part1\":Room 301\r\n" +
"DTSTART:20260501T100000Z\r\n" +
"DTEND:20260501T110000Z\r\n" +
"END:VEVENT\r\nEND:VCALENDAR\r\n"
ev := ParseEvent(ics)
if ev == nil {
t.Fatal("ParseEvent returned nil")
}
if ev.UID != "lowercased-uid-value" {
t.Errorf("UID: got %q", ev.UID)
}
if ev.Summary != "Team Sync" {
t.Errorf("Summary: got %q", ev.Summary)
}
if ev.Location != "Room 301" {
t.Errorf("Location: got %q", ev.Location)
}
}
func TestParseEvent_StartEndUTCInOutput(t *testing.T) {
// Verify that times with TZID are parsed with correct offset
// (UTC normalization in output is done by the helpers layer; parser
// returns time.Time which callers can call .UTC() on).
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
"DTSTART;TZID=Asia/Shanghai:20260501T180000\r\n" +
"DTEND;TZID=Asia/Shanghai:20260501T190000\r\n" +
"END:VEVENT\r\nEND:VCALENDAR\r\n"
ev := ParseEvent(ics)
if ev == nil {
t.Fatal("ParseEvent returned nil")
}
wantStart := "2026-05-01T10:00:00Z"
if got := ev.Start.UTC().Format(time.RFC3339); got != wantStart {
t.Errorf("Start UTC: got %q, want %q", got, wantStart)
}
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package ics
import (
"strings"
"time"
)
// mailtoScheme is the canonical case for the RFC 5545 ORGANIZER / ATTENDEE
// CAL-ADDRESS URI scheme. Emitted by the builder in upper-case to match
// Feishu client output; matched case-insensitively by the parser.
const mailtoScheme = "MAILTO:"
// ParsedEvent holds key fields extracted from an ICS VCALENDAR.
type ParsedEvent struct {
Method string // VCALENDAR-level METHOD (REQUEST/REPLY/CANCEL)
IsLarkDraft bool // true when VCALENDAR contains X-LARK-MAIL-DRAFT (Feishu private property indicating the event is editable)
UID string // VEVENT UID
Summary string // VEVENT SUMMARY, RFC 5545 TEXT unescaped
Location string // VEVENT LOCATION, RFC 5545 TEXT unescaped
Start time.Time // VEVENT DTSTART
End time.Time // VEVENT DTEND
Organizer string // ORGANIZER email (from MAILTO: URI or bare email)
Attendees []string // ATTENDEE emails (from MAILTO: URIs or bare emails)
OriginalTime int64 // RECURRENCE-ID as Unix seconds, 0 if not present. Used together with UID to derive the Feishu calendar event_id = UID + "_" + OriginalTime.
}
// ParseEvent extracts key fields from an ICS VCALENDAR string.
// Returns nil if no VEVENT is found.
func ParseEvent(icsText string) *ParsedEvent {
// Step 1: line unfolding (RFC 5545 §3.1)
unfolded := unfoldLines(icsText)
lines := strings.Split(unfolded, "\n")
var event ParsedEvent
inVEvent := false
foundVEvent := false
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
upper := strings.ToUpper(line)
// VCALENDAR-level properties
if !inVEvent && strings.HasPrefix(upper, "METHOD:") {
event.Method = strings.TrimSpace(line[len("METHOD:"):])
continue
}
if !inVEvent && strings.HasPrefix(upper, "X-LARK-MAIL-DRAFT:") {
event.IsLarkDraft = true
continue
}
if upper == "BEGIN:VEVENT" {
inVEvent = true
continue
}
if upper == "END:VEVENT" {
inVEvent = false
foundVEvent = true
continue
}
if !inVEvent {
continue
}
// VEVENT properties — RFC 5545 §3.1: property names are
// case-insensitive and may carry parameters (NAME;PARAM=v:value).
name, value := splitProperty(line)
propUpper := strings.ToUpper(name)
switch {
case propUpper == "UID" || strings.HasPrefix(propUpper, "UID;"):
event.UID = value
case propUpper == "SUMMARY" || strings.HasPrefix(propUpper, "SUMMARY;"):
event.Summary = unescapeTextValue(value)
case propUpper == "LOCATION" || strings.HasPrefix(propUpper, "LOCATION;"):
event.Location = unescapeTextValue(value)
case propUpper == "DTSTART" || strings.HasPrefix(propUpper, "DTSTART;"):
event.Start = parseICSTime(value, name)
case propUpper == "DTEND" || strings.HasPrefix(propUpper, "DTEND;"):
event.End = parseICSTime(value, name)
case propUpper == "RECURRENCE-ID" || strings.HasPrefix(propUpper, "RECURRENCE-ID;"):
if t := parseICSTime(value, name); !t.IsZero() {
event.OriginalTime = t.Unix()
}
case propUpper == "ORGANIZER" || strings.HasPrefix(propUpper, "ORGANIZER;"):
if email := extractMailto(value); email != "" {
event.Organizer = email
}
case propUpper == "ATTENDEE" || strings.HasPrefix(propUpper, "ATTENDEE;"):
if email := extractMailto(value); email != "" {
event.Attendees = append(event.Attendees, email)
}
}
}
if !foundVEvent {
return nil
}
return &event
}
// unfoldLines reverses RFC 5545 line folding: CRLF (or bare LF) followed by
// a single whitespace character is merged back into the preceding line.
// CRLF forms are handled first so that "\r\n " is consumed as a unit and does
// not leave a stray "\r" for the LF-only pass to mis-process.
func unfoldLines(s string) string {
s = strings.ReplaceAll(s, "\r\n ", "")
s = strings.ReplaceAll(s, "\r\n\t", "")
// LF-only folding — produced by some mail servers that strip \r.
s = strings.ReplaceAll(s, "\n ", "")
s = strings.ReplaceAll(s, "\n\t", "")
return s
}
// splitProperty splits "NAME;PARAMS:VALUE" into (name-with-params, value).
// It scans for the first colon that is not inside a double-quoted parameter
// value (e.g. CN="Doe: Jane"), per RFC 5545 §3.1.
func splitProperty(line string) (string, string) {
inQuote := false
for i := 0; i < len(line); i++ {
switch line[i] {
case '"':
inQuote = !inQuote
case ':':
if !inQuote {
return line[:i], line[i+1:]
}
}
}
return line, ""
}
// parseICSTime parses ICS datetime formats:
// - 20260420T060000Z (UTC)
// - TZID=Asia/Shanghai:20260420T140000 (with timezone in property params)
// - 20260420T140000 (local, treated as UTC)
func parseICSTime(value, propName string) time.Time {
value = strings.TrimSpace(value)
// Check for TZID in property params: DTSTART;TZID=Asia/Shanghai
// Case-insensitive search (RFC 5545 §3.2 param names are case-insensitive).
// Stop at the next ';' so trailing params like ;VALUE=DATE-TIME are excluded.
if idx := strings.Index(strings.ToUpper(propName), "TZID="); idx >= 0 {
tzPart := propName[idx+5:] // skip past "TZID="
if end := strings.IndexByte(tzPart, ';'); end >= 0 {
tzPart = tzPart[:end]
}
if loc, err := time.LoadLocation(tzPart); err == nil {
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
return t
}
}
}
// UTC format: YYYYMMDDTHHMMSSZ
if t, err := time.Parse("20060102T150405Z", value); err == nil {
return t
}
// Date-only: YYYYMMDD (all-day events)
if t, err := time.Parse("20060102", value); err == nil {
return t
}
// Local time without timezone (treat as UTC)
if t, err := time.Parse("20060102T150405", value); err == nil {
return t
}
return time.Time{}
}
// unescapeTextValue reverses escapeTextValue per RFC 5545 §3.3.11, turning
// the ICS on-wire representation back into a plain Go string. Only applied
// to TEXT-typed properties (SUMMARY, LOCATION, DESCRIPTION, etc.) —
// identifiers, date-times, and URIs are parsed as-is.
func unescapeTextValue(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n', 'N':
b.WriteByte('\n')
i++
continue
case '\\', ';', ',':
b.WriteByte(s[i+1])
i++
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
// extractMailto extracts the email address from an ICS ORGANIZER/ATTENDEE value.
// Accepts both "mailto:user@example.com" (RFC 5545 standard, case-insensitive per
// RFC 3986 §3.1) and a bare "user@example.com" value (observed in backend-regenerated
// ICS where the mailto: scheme prefix is dropped).
func extractMailto(value string) string {
value = strings.TrimSpace(value)
lower := strings.ToLower(value)
if idx := strings.Index(lower, strings.ToLower(mailtoScheme)); idx >= 0 {
return strings.TrimSpace(value[idx+len(mailtoScheme):])
}
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t") {
return value
}
return ""
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// calendarEventArgs are CLI flags that embed a calendar event in a compose command.
var calendarEventArgs = []string{
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
}
// extractEMLFromDraftsStub decodes the base64url EML from the captured request body.
func extractEMLFromDraftsStub(t *testing.T, stub *httpmock.Stub) string {
t.Helper()
var reqBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
raw, _ := reqBody["raw"].(string)
decoded, err := base64.URLEncoding.DecodeString(raw)
if err != nil {
t.Fatalf("base64url decode raw: %v", err)
}
return string(decoded)
}
// assertCalendarInEML checks that the decoded EML contains a text/calendar part.
func assertCalendarInEML(t *testing.T, eml string) {
t.Helper()
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar part in EML:\n%s", eml)
}
if !strings.Contains(eml, "method=REQUEST") {
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
}
}
// stubSourceMessage registers the minimum stubs to fetch a simple source message
// (used by reply/forward/reply-all).
func stubSourceMessage(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_001",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "msg_001",
"thread_id": "thread_001",
"smtp_message_id": "<msg_001@example.com>",
"subject": "Re: Original",
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
"cc": []interface{}{},
"bcc": []interface{}{},
"body_html": base64.URLEncoding.EncodeToString([]byte("<p>Original</p>")),
"body_plain_text": base64.URLEncoding.EncodeToString([]byte("Original")),
"internal_date": "1704067200000",
"attachments": []interface{}{},
},
},
},
})
}
// ---------------------------------------------------------------------------
// +reply with calendar event
// ---------------------------------------------------------------------------
func TestReply_WithCalendarEvent(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessage(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
}
reg.Register(draftsStub)
args := append([]string{
"+reply",
"--message-id", "msg_001",
"--body", "<p>Let us meet</p>",
}, calendarEventArgs...)
if err := runMountedMailShortcut(t, MailReply, args, f, stdout); err != nil {
t.Fatalf("+reply with calendar failed: %v", err)
}
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
}
// ---------------------------------------------------------------------------
// +reply-all with calendar event
// ---------------------------------------------------------------------------
func TestReplyAll_WithCalendarEvent(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessage(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
}
reg.Register(draftsStub)
args := append([]string{
"+reply-all",
"--message-id", "msg_001",
"--body", "<p>Let us meet</p>",
}, calendarEventArgs...)
if err := runMountedMailShortcut(t, MailReplyAll, args, f, stdout); err != nil {
t.Fatalf("+reply-all with calendar failed: %v", err)
}
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
}
// ---------------------------------------------------------------------------
// +forward with calendar event
// ---------------------------------------------------------------------------
func TestForward_WithCalendarEvent(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessage(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_001"},
},
}
reg.Register(draftsStub)
args := append([]string{
"+forward",
"--message-id", "msg_001",
"--to", "carol@example.com",
"--body", "<p>FYI</p>",
}, calendarEventArgs...)
if err := runMountedMailShortcut(t, MailForward, args, f, stdout); err != nil {
t.Fatalf("+forward with calendar failed: %v", err)
}
assertCalendarInEML(t, extractEMLFromDraftsStub(t, draftsStub))
}

View File

@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveComposeMailboxID(runtime)
@@ -90,6 +91,9 @@ var MailDraftCreate = common.Shortcut{
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); 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
}
@@ -300,6 +304,9 @@ func buildRawEMLForDraftCreate(
return "", err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes

View File

@@ -5,6 +5,8 @@ package mail
import (
"context"
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
@@ -14,6 +16,30 @@ import (
"github.com/spf13/cobra"
)
// newRuntimeWithEventFlags creates a RuntimeContext with --from and calendar event flags.
func newRuntimeWithEventFlags(from, summary, start, end, location string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"from", "mailbox", "event-summary", "event-start", "event-end", "event-location"} {
cmd.Flags().String(name, "", "")
}
if from != "" {
_ = cmd.Flags().Set("from", from)
}
if summary != "" {
_ = cmd.Flags().Set("event-summary", summary)
}
if start != "" {
_ = cmd.Flags().Set("event-start", start)
}
if end != "" {
_ = cmd.Flags().Set("event-end", end)
}
if location != "" {
_ = cmd.Flags().Set("event-location", location)
}
return &common.RuntimeContext{Cmd: cmd}
}
// newRuntimeWithFrom creates a minimal RuntimeContext with --from flag set.
func newRuntimeWithFrom(from string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
@@ -269,6 +295,31 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
}
}
func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
rt := newRuntimeWithEventFlags("sender@example.com", "Team Sync", "2026-05-10T10:00+08:00", "2026-05-10T11:00+08:00", "Room 301")
input := draftCreateInput{
From: "sender@example.com",
To: "alice@example.com",
Subject: "Team Sync",
Body: "<p>Please join us</p>",
}
rawEML, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar part in EML:\n%s", eml)
}
if !strings.Contains(eml, "method=REQUEST") {
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
}
if !strings.Contains(eml, "multipart/alternative") {
t.Errorf("expected calendar inside multipart/alternative:\n%s", eml)
}
}
// TestMailDraftCreatePrettyOutputsReference verifies mail draft create pretty outputs reference.
func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
@@ -316,3 +367,56 @@ func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
t.Fatalf("expected reference in pretty output, got: %s", out)
}
}
func TestMailDraftCreate_WithCalendarEventFlags(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
},
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
reg.Register(draftsStub)
err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--to", "alice@example.com",
"--subject", "Team Sync",
"--body", "<p>Please join us</p>",
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
"--event-location", "Room 301",
}, f, stdout)
if err != nil {
t.Fatalf("draft create with calendar failed: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal captured request body: %v", err)
}
raw, _ := reqBody["raw"].(string)
decoded, decErr := base64.URLEncoding.DecodeString(raw)
if decErr != nil {
t.Fatalf("base64url decode raw: %v", decErr)
}
eml := string(decoded)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar in EML:\n%s", eml)
}
if !strings.Contains(eml, "Team Sync") {
t.Errorf("expected event summary in ICS:\n%s", eml)
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/ics"
)
// MailDraftEdit is the `+draft-edit` shortcut: update an existing draft
@@ -37,6 +38,11 @@ var MailDraftEdit = common.Shortcut{
{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: "set-event-summary", Desc: "Set calendar event title. Must be used together with --set-event-start and --set-event-end."},
{Name: "set-event-start", Desc: "Set calendar event start time (ISO 8601)."},
{Name: "set-event-end", Desc: "Set calendar event end time (ISO 8601)."},
{Name: "set-event-location", Desc: "Set calendar event location."},
{Name: "remove-event", Type: "bool", Desc: "Remove the calendar event from the draft."},
{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."},
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."},
},
@@ -97,8 +103,9 @@ 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.
// Pre-process ops that need snapshot context: resolve signature using
// the draft's From address, and build ICS for set_calendar using the
// draft's From/To/Cc so organizer and attendee addresses are correct.
var draftFromEmail string
if len(snapshot.From) > 0 {
draftFromEmail = snapshot.From[0].Address
@@ -123,7 +130,8 @@ var MailDraftEdit = common.Shortcut{
})
}
for i := range patch.Ops {
if patch.Ops[i].Op == "insert_signature" {
switch patch.Ops[i].Op {
case "insert_signature":
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
if sigErr != nil {
return sigErr
@@ -132,6 +140,32 @@ var MailDraftEdit = common.Shortcut{
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
patch.Ops[i].SignatureImages = sigResult.Images
}
case "set_calendar":
if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil {
parsed := ics.ParseEvent(string(calPart.Body))
if parsed == nil || !parsed.IsLarkDraft {
return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one")
}
}
if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil {
return output.ErrValidation("set_calendar: %v", err)
}
// Derive effective To/Cc by replaying all pending recipient ops so
// the ICS ATTENDEE list matches the final post-edit recipients.
toAddrs, ccAddrs := effectiveRecipients(snapshot, patch.Ops)
calData := buildCalendarBodyFromArgs(
patch.Ops[i].EventSummary,
patch.Ops[i].EventStart,
patch.Ops[i].EventEnd,
patch.Ops[i].EventLocation,
draftFromEmail,
joinAddresses(toAddrs),
joinAddresses(ccAddrs),
)
if calData == nil {
return output.ErrValidation("set_calendar: failed to build ICS from event fields")
}
patch.Ops[i].CalendarICS = calData
}
}
// Pre-process add_attachment ops for large attachment support:
@@ -346,6 +380,39 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
}
}
// --set-event-* / --remove-event → set_calendar / remove_calendar op.
// The ICS blob itself is pre-built at Execute time once the snapshot's
// organizer/attendee addresses are available; here we only record the
// user-supplied fields and validate the flag combination.
hasEventSet := runtime.Str("set-event-summary") != ""
hasEventRemove := runtime.Bool("remove-event")
if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") {
return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary")
}
if hasEventSet && hasEventRemove {
return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive")
}
if hasEventSet {
summary := runtime.Str("set-event-summary")
start := runtime.Str("set-event-start")
end := runtime.Str("set-event-end")
if summary == "" || start == "" || end == "" {
return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together")
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error())
}
patch.Ops = append(patch.Ops, draftpkg.PatchOp{
Op: "set_calendar",
EventSummary: summary,
EventStart: start,
EventEnd: end,
EventLocation: runtime.Str("set-event-location"),
})
} else if hasEventRemove {
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_calendar"})
}
if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") {
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)")
}
@@ -491,3 +558,45 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",
}
}
// effectiveRecipients returns the To and Cc address slices that will result
// after all pending set_recipients / add_recipient / remove_recipient ops in
// ops have been applied. Used by the set_calendar pre-processor to build ICS
// with the correct post-edit ATTENDEE list before Apply() runs.
func effectiveRecipients(snapshot *draftpkg.DraftSnapshot, ops []draftpkg.PatchOp) (to, cc []draftpkg.Address) {
to = append([]draftpkg.Address{}, snapshot.To...)
cc = append([]draftpkg.Address{}, snapshot.Cc...)
apply := func(addrs []draftpkg.Address, op draftpkg.PatchOp) []draftpkg.Address {
switch op.Op {
case "set_recipients":
return append([]draftpkg.Address{}, op.Addresses...)
case "add_recipient":
for _, a := range addrs {
if strings.EqualFold(a.Address, op.Address) {
return addrs
}
}
return append(addrs, draftpkg.Address{Name: op.Name, Address: op.Address})
case "remove_recipient":
next := addrs[:0:0]
for _, a := range addrs {
if !strings.EqualFold(a.Address, op.Address) {
next = append(next, a)
}
}
return next
}
return addrs
}
for _, op := range ops {
switch op.Field {
case "to":
to = apply(to, op)
case "cc":
cc = apply(cc, op)
}
}
return to, cc
}

View File

@@ -18,9 +18,11 @@ func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
for _, name := range []string{
"set-subject", "set-to", "set-cc", "set-bcc",
"set-priority", "patch-file",
"set-event-summary", "set-event-start", "set-event-end", "set-event-location",
} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().Bool("remove-event", false, "")
for name, val := range flags {
_ = cmd.Flags().Set(name, val)
}
@@ -115,3 +117,115 @@ func TestPrettyDraftAddresses(t *testing.T) {
})
}
}
func TestBuildDraftEditPatch_SetEventEmitsSetCalendarOp(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"set-event-summary": "Team Sync",
"set-event-start": "2026-05-10T10:00:00+08:00",
"set-event-end": "2026-05-10T11:00:00+08:00",
"set-event-location": "Room 301",
})
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: %+v", len(patch.Ops), patch.Ops)
}
op := patch.Ops[0]
if op.Op != "set_calendar" {
t.Errorf("Op = %q, want set_calendar", op.Op)
}
if op.EventSummary != "Team Sync" {
t.Errorf("EventSummary = %q, want Team Sync", op.EventSummary)
}
if op.EventLocation != "Room 301" {
t.Errorf("EventLocation = %q, want Room 301", op.EventLocation)
}
}
func TestBuildDraftEditPatch_RemoveEventEmitsRemoveCalendarOp(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"remove-event": "true",
})
patch, err := buildDraftEditPatch(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patch.Ops) != 1 || patch.Ops[0].Op != "remove_calendar" {
t.Fatalf("expected single remove_calendar op, got %+v", patch.Ops)
}
}
func TestBuildDraftEditPatch_SetAndRemoveEventMutuallyExclusive(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"set-event-summary": "Meeting",
"remove-event": "true",
})
_, err := buildDraftEditPatch(rt)
if err == nil {
t.Fatal("expected error for --set-event-summary + --remove-event, got nil")
}
}
func TestBuildDraftEditPatch_SetEventMissingStartEnd(t *testing.T) {
rt := newDraftEditRuntime(map[string]string{
"set-event-summary": "Meeting",
})
_, err := buildDraftEditPatch(rt)
if err == nil {
t.Fatal("expected error when --set-event-summary set without start/end, got nil")
}
}
func TestEffectiveRecipients_SetReplaces(t *testing.T) {
snapshot := &draftpkg.DraftSnapshot{
To: []draftpkg.Address{{Address: "old@example.com"}},
Cc: []draftpkg.Address{{Address: "cc@example.com"}},
}
ops := []draftpkg.PatchOp{
{Op: "set_recipients", Field: "to", Addresses: []draftpkg.Address{{Address: "new@example.com"}}},
}
to, cc := effectiveRecipients(snapshot, ops)
if len(to) != 1 || to[0].Address != "new@example.com" {
t.Errorf("expected to=[new@example.com], got %v", to)
}
if len(cc) != 1 || cc[0].Address != "cc@example.com" {
t.Errorf("expected cc unchanged, got %v", cc)
}
}
func TestEffectiveRecipients_AddAndRemove(t *testing.T) {
snapshot := &draftpkg.DraftSnapshot{
To: []draftpkg.Address{{Address: "alice@example.com"}, {Address: "bob@example.com"}},
}
ops := []draftpkg.PatchOp{
{Op: "add_recipient", Field: "to", Address: "carol@example.com"},
{Op: "remove_recipient", Field: "to", Address: "bob@example.com"},
}
to, _ := effectiveRecipients(snapshot, ops)
if len(to) != 2 {
t.Fatalf("expected 2 recipients, got %v", to)
}
addrs := map[string]bool{}
for _, a := range to {
addrs[a.Address] = true
}
if !addrs["alice@example.com"] || !addrs["carol@example.com"] || addrs["bob@example.com"] {
t.Errorf("unexpected recipient set: %v", to)
}
}
func TestEffectiveRecipients_NoOpsReturnsCopy(t *testing.T) {
snapshot := &draftpkg.DraftSnapshot{
To: []draftpkg.Address{{Address: "alice@example.com"}},
Cc: []draftpkg.Address{{Address: "bob@example.com"}},
}
to, cc := effectiveRecipients(snapshot, nil)
if len(to) != 1 || to[0].Address != "alice@example.com" {
t.Errorf("unexpected to: %v", to)
}
if len(cc) != 1 || cc[0].Address != "bob@example.com" {
t.Errorf("unexpected cc: %v", cc)
}
}

View File

@@ -43,7 +43,8 @@ var MailForward = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -74,6 +75,9 @@ var MailForward = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
@@ -87,6 +91,9 @@ var MailForward = common.Shortcut{
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
@@ -295,6 +302,11 @@ var MailForward = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil {
bld = bld.CalendarBody(calData)
} else if len(sourceMsg.OriginalCalendarICS) > 0 {
bld = bld.CalendarBody(sourceMsg.OriginalCalendarICS)
}
// Download original attachments, separating normal from large.
type downloadedAtt struct {
content []byte

View File

@@ -41,7 +41,8 @@ var MailReply = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -75,12 +76,18 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
@@ -287,6 +294,9 @@ var MailReply = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, replyTo, ccFlag); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes

View File

@@ -42,7 +42,8 @@ var MailReplyAll = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -76,12 +77,18 @@ var MailReplyAll = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), ""); err != nil {
return err
}
@@ -296,6 +303,9 @@ var MailReplyAll = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, toList, ccList); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, srcInlineBytes) + templateSmallBytes

View File

@@ -39,7 +39,8 @@ var MailSend = common.Shortcut{
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
signatureFlag,
priorityFlag},
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -87,6 +88,9 @@ var MailSend = common.Shortcut{
return err
}
}
if err := validateEventSendTimeExclusion(runtime); err != nil {
return err
}
if err := validateSendTime(runtime); err != nil {
return err
}
@@ -96,6 +100,9 @@ var MailSend = common.Shortcut{
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
return validatePriorityFlag(runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -249,6 +256,9 @@ var MailSend = common.Shortcut{
return err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes

View File

@@ -4,6 +4,9 @@
package mail
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
@@ -213,3 +216,55 @@ func TestMailSendSaveDraftOutputsReference(t *testing.T) {
t.Fatalf("reference = %v", data["reference"])
}
}
func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
draftsStub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
},
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"primary_email_address": "me@example.com"},
},
})
reg.Register(draftsStub)
err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--to", "alice@example.com",
"--subject", "Team Sync",
"--body", "<p>Please join us</p>",
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
}, f, stdout)
if err != nil {
t.Fatalf("mail send with calendar failed: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
raw, _ := reqBody["raw"].(string)
decoded, decErr := base64.URLEncoding.DecodeString(raw)
if decErr != nil {
t.Fatalf("base64url decode: %v", decErr)
}
eml := string(decoded)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar in EML:\n%s", eml)
}
if !strings.Contains(eml, "method=REQUEST") {
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
}
}

View File

@@ -0,0 +1,530 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"path"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
type markdownUploadSpec struct {
FileToken string
FileName string
FolderToken string
FilePath string
Content string
ContentSet bool
FileSet bool
}
type markdownUploadResult struct {
FileToken string
Version string
}
type markdownMultipartSession struct {
UploadID string
BlockSize int64
BlockNum int
}
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
switch {
case spec.ContentSet && spec.FileSet:
return common.FlagErrorf("--content and --file are mutually exclusive")
case !spec.ContentSet && !spec.FileSet:
return common.FlagErrorf("specify exactly one of --content or --file")
}
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if requireName && spec.ContentSet {
if strings.TrimSpace(spec.FileName) == "" {
return common.FlagErrorf("--name is required when using --content")
}
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
return err
}
}
if spec.FileSet {
if strings.TrimSpace(spec.FilePath) == "" {
return common.FlagErrorf("--file cannot be empty")
}
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
}
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
return err
}
}
if spec.FileName != "" {
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
return err
}
}
return nil
}
func validateMarkdownFileName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return common.FlagErrorf("%s cannot be empty", flagName)
}
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
return common.FlagErrorf("%s must end with .md", flagName)
}
return nil
}
func finalMarkdownFileName(spec markdownUploadSpec) string {
if strings.TrimSpace(spec.FileName) != "" {
return strings.TrimSpace(spec.FileName)
}
if strings.TrimSpace(spec.FilePath) == "" {
return ""
}
return filepath.Base(spec.FilePath)
}
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
var size int64
if spec.ContentSet {
size = int64(len(spec.Content))
} else {
if strings.TrimSpace(spec.FilePath) == "" {
return 0, common.FlagErrorf("--file cannot be empty")
}
info, err := runtime.FileIO().Stat(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
}
size = info.Size()
}
if size == 0 {
return 0, output.ErrValidation("%s", markdownEmptyContentError)
}
return size, nil
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath
}
return "<markdown content>"
}
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := finalMarkdownFileName(spec)
if !multipart {
body := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
"file": markdownDryRunFileField(spec),
}
if spec.FileToken != "" {
body["file_token"] = spec.FileToken
}
desc := "multipart/form-data upload"
if spec.FileToken != "" {
desc = "multipart/form-data overwrite upload"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/upload_all").
Body(body)
}
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
}
if spec.FileToken != "" {
prepareBody["file_token"] = spec.FileToken
}
desc := "3-step multipart upload"
if spec.FileToken != "" {
desc = "3-step multipart overwrite upload"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[1] Initialize multipart upload").
Body(prepareBody).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[2] Upload file parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[3] Finalize upload and get file_token/version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
}
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = finalMarkdownFileName(spec)
}
if fileName != "" {
spec.FileName = fileName
return markdownUploadDryRun(spec, fileSize, multipart)
}
dry := common.NewDryRunAPI().Desc("Fetch the existing file name, then overwrite the file content")
dry.POST("/open-apis/drive/v1/metas/batch_query").
Desc("[1] Read current file metadata to preserve the existing file name").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": spec.FileToken,
"doc_type": "file",
},
},
})
spec.FileName = "<existing_remote_name_or_" + spec.FileToken + ".md>"
if !multipart {
dry.POST("/open-apis/drive/v1/files/upload_all").
Desc("[2] Overwrite file contents with multipart/form-data upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
"file": markdownDryRunFileField(spec),
"file_token": spec.FileToken,
})
return dry
}
dry.POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[2] Initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[3] Upload file parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[4] Finalize upload and get file_token/version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry
}
func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte) (markdownUploadResult, error) {
fileName := finalMarkdownFileName(spec)
fileSize := int64(len(payload))
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
}
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
}
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
fileName := finalMarkdownFileName(spec)
f, err := runtime.FileIO().Open(spec.FilePath)
if err != nil {
return markdownUploadResult{}, common.WrapInputStatError(err)
}
defer f.Close()
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
}
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
}
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", "explorer")
fd.AddField("parent_node", spec.FolderToken)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return markdownUploadResult{}, err
}
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
}
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(data, spec.FileToken != "")
}
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
}
if spec.FileToken != "" {
prepareBody["file_token"] = spec.FileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return markdownUploadResult{}, err
}
session, err := parseMarkdownMultipartSession(prepareResult)
if err != nil {
return markdownUploadResult{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
return markdownUploadResult{}, err
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": session.UploadID,
"block_num": session.BlockNum,
})
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
}
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
session := markdownMultipartSession{
UploadID: common.GetString(data, "upload_id"),
BlockSize: int64(common.GetFloat(data, "block_size")),
BlockNum: int(common.GetFloat(data, "block_num")),
}
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
session.UploadID, session.BlockSize, session.BlockNum)
}
return session, nil
}
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
if session.BlockNum != expectedBlocks {
return output.Errorf(
output.ExitAPI,
"api_error",
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
session.BlockSize,
session.BlockNum,
expectedBlocks,
payloadSize,
)
}
maxInt := int64(^uint(0) >> 1)
if session.BlockSize > maxInt {
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(session.BlockSize))
remaining := payloadSize
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
if readErr != nil {
return output.ErrValidation("cannot read file: %s", readErr)
}
fd := larkcore.NewFormdata()
fd.AddField("upload_id", session.UploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", n))
fd.AddFile("file", bytes.NewReader(buffer[:n]))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
}
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, common.FormatSize(int64(n)))
remaining -= int64(n)
}
if remaining != 0 {
return output.Errorf(
output.ExitAPI,
"api_error",
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
remaining,
session.BlockNum,
)
}
return nil
}
func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool) (markdownUploadResult, error) {
result := markdownUploadResult{
FileToken: common.GetString(data, "file_token"),
Version: common.GetString(data, "version"),
}
if result.Version == "" {
result.Version = common.GetString(data, "data_version")
}
if result.FileToken == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
if requireVersion && result.Version == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
}
return result, nil
}
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
}
meta, _ := metas[0].(map[string]interface{})
return common.GetString(meta, "title"), nil
}
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
version := common.GetString(data, "version")
if version == "" {
version = common.GetString(data, "data_version")
}
if version != "" {
fmt.Fprintf(w, "version: %s\n", version)
}
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
if grant := common.GetMap(data, "permission_grant"); grant != nil {
fmt.Fprintf(w, "permission_grant.status: %s\n", common.GetString(grant, "status"))
fmt.Fprintf(w, "permission_grant.perm: %s\n", common.GetString(grant, "perm"))
}
}
func prettyPrintMarkdownSavedFile(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path"))
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
}
func prettyPrintMarkdownContent(w io.Writer, data map[string]interface{}) {
fmt.Fprint(w, common.GetString(data, "content"))
}
func fileNameFromDownloadHeader(header http.Header, fallback string) string {
name := fallback
if header != nil {
if headerName := larkcore.FileNameByHeader(header); strings.TrimSpace(headerName) != "" {
name = headerName
}
}
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
name = path.Base(name)
if name == "" || name == "." || name == ".." {
return fallback
}
return name
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var MarkdownCreate = common.Shortcut{
Service: "markdown",
Command: "+create",
Description: "Create a Markdown file in Drive",
Risk: "write",
Scopes: []string{"drive:file:upload"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "file", Desc: "local .md file path"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, true)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return err
}
var result markdownUploadResult
if spec.FileSet {
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
} else {
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
}
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": result.FileToken,
"file_name": finalMarkdownFileName(spec),
"size_bytes": fileSize,
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
out["permission_grant"] = grant
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownWrite(w, out)
})
return nil
},
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var MarkdownFetch = common.Shortcut{
Service: "markdown",
Command: "+fetch",
Description: "Fetch a Markdown file from Drive",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "Markdown file token", Required: true},
{Name: "output", Desc: "local save path or directory; omit to return content directly"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing local output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath == "" {
return nil
}
if _, err := validate.SafeOutputPath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI().
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/files/:file_token/download").
Set("file_token", runtime.Str("file-token"))
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath)
} else {
dry.Set("output", "<stdout>")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
outputPath := strings.TrimSpace(runtime.Str("output"))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
defer resp.Body.Close()
fileName := fileNameFromDownloadHeader(resp.Header, fileToken+".md")
if outputPath == "" {
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
out := map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"content": string(payload),
"size_bytes": len(payload),
}
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
prettyPrintMarkdownContent(w, out)
})
return nil
}
if markdownFetchOutputIsDirectory(runtime, outputPath) {
outputPath = filepath.Join(outputPath, fileName)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
if savedPath == "" {
savedPath = outputPath
}
out := map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"saved_path": savedPath,
"size_bytes": result.Size(),
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownSavedFile(w, out)
})
return nil
},
}
func markdownFetchOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var MarkdownOverwrite = common.Shortcut{
Service: "markdown",
Command: "+overwrite",
Description: "Overwrite an existing Markdown file in Drive",
Risk: "write",
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "name", Desc: "optional file name with .md suffix; overrides the existing/local file name"},
{Name: "content", Desc: "new Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "file", Desc: "local .md file path"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileToken: fileToken,
FileName: strings.TrimSpace(runtime.Str("name")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, false)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FileName: strings.TrimSpace(runtime.Str("name")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return markdownOverwriteDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
spec := markdownUploadSpec{
FileToken: fileToken,
FileName: strings.TrimSpace(runtime.Str("name")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return err
}
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
if err != nil {
return err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = fileToken + ".md"
}
spec.FileName = fileName
var result markdownUploadResult
if spec.FileSet {
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
} else {
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
}
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": result.FileToken,
"file_name": fileName,
"version": result.Version,
"size_bytes": fileSize,
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownWrite(w, out)
})
return nil
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all markdown shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownFetch,
MarkdownOverwrite,
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
minutesUploadSupportedFormatsTip = "Supported audio formats: wav, mp3, m4a, aac, ogg, wma, amr; supported video formats: avi, wmv, mov, mp4, m4v, mpeg, ogg, flv."
minutesUploadLimitsTip = "The original uploaded media must be no larger than 6GB and no longer than 6 hours."
)
// MinutesUpload uploads a media file token to generate a minute.
var MinutesUpload = common.Shortcut{
Service: "minutes",
Command: "+upload",
Description: "Upload a media file token to generate a minute",
Risk: "write",
Scopes: []string{"minutes:minutes.upload:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true},
},
Tips: []string{
"This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.",
minutesUploadSupportedFormatsTip,
minutesUploadLimitsTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
if fileToken == "" {
return output.ErrValidation("--file-token is required")
}
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/minutes/v1/minutes/upload").
Body(map[string]interface{}{"file_token": runtime.Str("file-token")})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
body := map[string]interface{}{
"file_token": fileToken,
}
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
if err != nil {
return err
}
minuteURL := common.GetString(data, "minute_url")
outData := map[string]interface{}{
"minute_url": minuteURL,
}
runtime.OutFormat(outData, nil, nil)
return nil
},
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func TestMinutesUpload_Validate(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing file token",
args: []string{"+upload", "--as", "user"},
wantErr: "required flag(s) \"file-token\" not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesUpload.Mount(parent, f)
parent.SetArgs(tt.args)
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
}
})
}
}
func TestMinutesUpload_HelpMetadata(t *testing.T) {
if len(MinutesUpload.Flags) == 0 {
t.Fatal("expected file-token flag metadata")
}
if got := MinutesUpload.Flags[0].Desc; !strings.Contains(got, "supported audio/video file") {
t.Fatalf("file-token description = %q, want supported media guidance", got)
}
joinedTips := strings.Join(MinutesUpload.Tips, "\n")
for _, want := range []string{
"drive +upload",
"wav, mp3, m4a, aac, ogg, wma, amr",
"avi, wmv, mov, mp4, m4v, mpeg, ogg, flv",
"6GB",
"6 hours",
} {
if !strings.Contains(joinedTips, want) {
t.Fatalf("tips should contain %q, got:\n%s", want, joinedTips)
}
}
}
func TestMinutesUpload_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/upload") {
t.Errorf("expected POST /open-apis/minutes/v1/minutes/upload, got:\n%s", out)
}
if !strings.Contains(out, "boxcn123456") {
t.Errorf("expected file token in body, got:\n%s", out)
}
}
func TestMinutesUpload_Execute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPost,
URL: "/open-apis/minutes/v1/minutes/upload",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"minute_url": "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c",
},
},
})
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var res map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
dataMap, _ := res["data"].(map[string]interface{})
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
}
}

View File

@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MinutesSearch,
MinutesDownload,
MinutesUpload,
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/shortcuts/event"
"github.com/larksuite/cli/shortcuts/im"
"github.com/larksuite/cli/shortcuts/mail"
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
"github.com/larksuite/cli/shortcuts/slides"
@@ -42,6 +43,7 @@ func init() {
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
@@ -90,6 +92,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
}
program.AddCommand(svc)
}
if service == "docs" {
doc.ConfigureServiceHelp(svc)
}
for _, shortcut := range shortcuts {
shortcut.MountWithContext(ctx, svc, f)

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package shortcuts
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, &cmdutil.Factory{})
for _, path := range [][]string{
{"markdown", "+create"},
{"markdown", "+fetch"},
{"markdown", "+overwrite"},
} {
cmd, _, err := program.Find(path)
if err != nil {
t.Fatalf("find markdown shortcut %v: %v", path, err)
}
if cmd == nil || cmd.Name() != path[1] {
t.Fatalf("markdown shortcut not mounted: %#v", cmd)
}
}
}

View File

@@ -4,16 +4,45 @@
package shortcuts
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func newRegisterTestFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
return f
}
func newRegisterTestProgramWithTipsHelp() *cobra.Command {
program := &cobra.Command{Use: "root"}
defaultHelp := program.HelpFunc()
program.SetHelpFunc(func(cmd *cobra.Command, args []string) {
defaultHelp(cmd, args)
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {
return
}
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range tips {
fmt.Fprintf(out, " • %s\n", tip)
}
})
return program
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, s := range allShortcuts {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
@@ -48,7 +77,7 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, &cmdutil.Factory{})
RegisterShortcuts(program, newRegisterTestFactory(t))
baseCmd, _, err := program.Find([]string{"base"})
if err != nil {
@@ -69,7 +98,7 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, &cmdutil.Factory{})
RegisterShortcuts(program, newRegisterTestFactory(t))
previewCmd, _, err := program.Find([]string{"docs", "+media-preview"})
if err != nil {
@@ -80,12 +109,182 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
docsCmd, _, err := program.Find([]string{"docs"})
if err != nil {
t.Fatalf("find docs command: %v", err)
}
if docsCmd == nil || docsCmd.Name() != "docs" {
t.Fatalf("docs command not mounted: %#v", docsCmd)
}
if docsCmd.Flags().Lookup("api-version") == nil {
t.Fatal("docs command should expose --api-version for versioned help")
}
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
}
var defaultHelp bytes.Buffer
docsCmd.SetOut(&defaultHelp)
if err := docsCmd.Help(); err != nil {
t.Fatalf("docs help failed: %v", err)
}
for _, want := range []string{
"Tips:",
"Agent version rule",
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
} {
if !strings.Contains(defaultHelp.String(), want) {
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
}
}
}
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
docsCmd, _, err := program.Find([]string{"docs"})
if err != nil {
t.Fatalf("find docs command: %v", err)
}
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
t.Fatalf("set docs api-version: %v", err)
}
var out bytes.Buffer
docsCmd.SetOut(&out)
if err := docsCmd.Help(); err != nil {
t.Fatalf("docs v2 help failed: %v", err)
}
for _, want := range []string{
"Document and content operations (v2).",
"Tips:",
"Agent version rule",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
}
}
}
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
tests := []struct {
name string
shortcut string
apiVersion string
shortcutHelp string
versionedFlag string
}{
{
name: "create v1",
shortcut: "+create",
apiVersion: "v1",
shortcutHelp: "Create a Lark document",
versionedFlag: "--markdown",
},
{
name: "create v2",
shortcut: "+create",
apiVersion: "v2",
shortcutHelp: "Create a Lark document",
versionedFlag: "--content",
},
{
name: "fetch v1",
shortcut: "+fetch",
apiVersion: "v1",
shortcutHelp: "Fetch Lark document content",
versionedFlag: "--offset",
},
{
name: "fetch v2",
shortcut: "+fetch",
apiVersion: "v2",
shortcutHelp: "Fetch Lark document content",
versionedFlag: "partial read scope",
},
{
name: "update v1",
shortcut: "+update",
apiVersion: "v1",
shortcutHelp: "Update a Lark document",
versionedFlag: "--mode",
},
{
name: "update v2",
shortcut: "+update",
apiVersion: "v2",
shortcutHelp: "Update a Lark document",
versionedFlag: "--command",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program := newRegisterTestProgramWithTipsHelp()
RegisterShortcuts(program, newRegisterTestFactory(t))
cmd, _, err := program.Find([]string{"docs", tt.shortcut})
if err != nil {
t.Fatalf("find docs %s command: %v", tt.shortcut, err)
}
if cmd == nil || cmd.Name() != tt.shortcut {
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
}
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
}
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Help(); err != nil {
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
}
for _, want := range []string{
tt.shortcutHelp,
tt.versionedFlag,
"Tips:",
"Agent version rule",
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
for _, unwanted := range []string{
"[NOTE]",
"Use --api-version v2 for the latest API",
} {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
}
}
})
}
}
func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
program := &cobra.Command{Use: "root"}
existingBase := &cobra.Command{Use: "base", Short: "existing base service"}
program.AddCommand(existingBase)
RegisterShortcuts(program, &cmdutil.Factory{})
RegisterShortcuts(program, newRegisterTestFactory(t))
baseCount := 0
for _, command := range program.Commands() {

View File

@@ -17,6 +17,21 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
func inferTaskMemberType(id string) string {
if strings.HasPrefix(strings.TrimSpace(id), "cli_") {
return "app"
}
return "user"
}
func buildTaskMember(id, role string) map[string]interface{} {
return map[string]interface{}{
"id": id,
"role": role,
"type": inferTaskMemberType(id),
}
}
// parseTaskTime converts a flexible time string into the Task API due/start object format.
func parseTaskTime(timeStr string) (map[string]interface{}, error) {
var msTs string
@@ -96,14 +111,15 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
body["description"] = desc
}
var members []map[string]interface{}
if assignee := runtime.Str("assignee"); assignee != "" {
body["members"] = []map[string]interface{}{
{
"id": assignee,
"role": "assignee",
"type": "user",
},
}
members = append(members, buildTaskMember(assignee, "assignee"))
}
if follower := runtime.Str("follower"); follower != "" {
members = append(members, buildTaskMember(follower, "follower"))
}
if len(members) > 0 {
body["members"] = members
}
if tasklistId := runtime.Str("tasklist-id"); tasklistId != "" {
@@ -147,7 +163,8 @@ var CreateTask = common.Shortcut{
Flags: []common.Flag{
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "assignee", Desc: "assignee open_id"},
{Name: "assignee", Desc: "task assignee id added during create; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "follower", Desc: "task follower id added during create; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "tasklist-id", Desc: "tasklist id or applink URL"},
{Name: "idempotency-key", Desc: "client token for idempotency"},

View File

@@ -28,8 +28,8 @@ var AssignTask = common.Shortcut{
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "add", Desc: "comma-separated open_ids to add as assignees"},
{Name: "remove", Desc: "comma-separated open_ids to remove from assignees"},
{Name: "add", Desc: "comma-separated assignee IDs to add; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "remove", Desc: "comma-separated assignee IDs to remove; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
},
@@ -43,16 +43,15 @@ var AssignTask = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
taskId := url.PathEscape(runtime.Str("task-id"))
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, runtime.Str("idempotency-key"))
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "")
body := buildMembersBody(removeStr, "assignee", "")
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
@@ -69,7 +68,7 @@ var AssignTask = common.Shortcut{
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, runtime.Str("idempotency-key"))
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
@@ -92,7 +91,7 @@ var AssignTask = common.Shortcut{
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "")
body := buildMembersBody(removeStr, "assignee", "")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
@@ -125,21 +124,21 @@ var AssignTask = common.Shortcut{
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task assignees updated successfully!\n")
fmt.Fprintf(w, "✅ Task assignes updated successfully!\n")
fmt.Fprintf(w, "Task ID: %s\n", taskId)
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
if members, ok := task["members"].([]interface{}); ok {
fmt.Fprintf(w, "Current Assignees: %d\n", len(members))
fmt.Fprintf(w, "Current Assignes: %d\n", len(members))
}
})
return nil
},
}
func buildMembersBody(idsStr string, clientToken string) map[string]interface{} {
func buildMembersBody(idsStr, role, clientToken string) map[string]interface{} {
ids := strings.Split(idsStr, ",")
var members []map[string]interface{}
@@ -148,11 +147,7 @@ func buildMembersBody(idsStr string, clientToken string) map[string]interface{}
if id == "" {
continue
}
members = append(members, map[string]interface{}{
"id": id,
"role": "assignee",
"type": "user",
})
members = append(members, buildTaskMember(id, role))
}
body := map[string]interface{}{

View File

@@ -6,14 +6,74 @@ package task
import (
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
func TestBuildMembersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildMembersBody("u1, u2 , ", "token1")
body := buildMembersBody("u1, u2 , ", "assignee", "token1")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(body["client_token"], convey.ShouldEqual, "token1")
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
convey.So(members[0]["type"], convey.ShouldEqual, "user")
})
convey.Convey("Build infers app assignee members from cli prefix", t, func() {
body := buildMembersBody("cli_bot_1", "assignee", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 1)
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
convey.So(members[0]["type"], convey.ShouldEqual, "app")
})
convey.Convey("Build infers mixed member types in one list", t, func() {
body := buildMembersBody("ou_user_1, cli_bot_1", "assignee", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(members[0]["type"], convey.ShouldEqual, "user")
convey.So(members[1]["type"], convey.ShouldEqual, "app")
})
}
func TestBuildTaskCreateBodySupportsAssigneeAndFollower(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("summary", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("tasklist-id", "", "")
cmd.Flags().String("idempotency-key", "", "")
cmd.Flags().String("data", "", "")
_ = cmd.Flags().Set("summary", "bot task")
_ = cmd.Flags().Set("assignee", "cli_bot_xxx")
_ = cmd.Flags().Set("follower", "ou_follower_xxx")
runtime := &common.RuntimeContext{Cmd: cmd}
body, err := buildTaskCreateBody(runtime)
if err != nil {
t.Fatalf("buildTaskCreateBody() error = %v", err)
}
members := body["members"].([]map[string]interface{})
if len(members) != 2 {
t.Fatalf("members len = %d, want 2", len(members))
}
if got := members[0]["type"]; got != "app" {
t.Fatalf("member[0] type = %v, want app", got)
}
if got := members[0]["role"]; got != "assignee" {
t.Fatalf("member[0] role = %v, want assignee", got)
}
if got := members[1]["type"]; got != "user" {
t.Fatalf("member[1] type = %v, want user", got)
}
if got := members[1]["role"]; got != "follower" {
t.Fatalf("member[1] role = %v, want follower", got)
}
}

View File

@@ -28,8 +28,8 @@ var FollowersTask = common.Shortcut{
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "add", Desc: "comma-separated open_ids to add as followers"},
{Name: "remove", Desc: "comma-separated open_ids to remove from followers"},
{Name: "add", Desc: "comma-separated follower IDs to add; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "remove", Desc: "comma-separated follower IDs to remove; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
},
@@ -144,11 +144,7 @@ func buildFollowersBody(idsStr string, clientToken string) map[string]interface{
if id == "" {
continue
}
members = append(members, map[string]interface{}{
"id": id,
"role": "follower",
"type": "user",
})
members = append(members, buildTaskMember(id, "follower"))
}
body := map[string]interface{}{

View File

@@ -15,5 +15,24 @@ func TestBuildFollowersBody(t *testing.T) {
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(body["client_token"], convey.ShouldEqual, "token1")
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
convey.So(members[0]["type"], convey.ShouldEqual, "user")
})
convey.Convey("Build infers app followers", t, func() {
body := buildFollowersBody("cli_bot_1", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 1)
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
convey.So(members[0]["type"], convey.ShouldEqual, "app")
})
convey.Convey("Build infers mixed follower types in one list", t, func() {
body := buildFollowersBody("ou_user_1, cli_bot_1", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(members[0]["type"], convey.ShouldEqual, "user")
convey.So(members[1]["type"], convey.ShouldEqual, "app")
})
}

View File

@@ -8,7 +8,7 @@
- `lark-cli base records create`
2. **优先使用 Shortcut** — 有 Shortcut 的操作不要手拼原生 API
3. **写记录前** — 先调用 `table.fields list` 获取字段 `type/ui_type`,再读 [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md);该文档是 CellValue 的 source of truth
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型`property` 结构
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型 JSON 结构
5. **筛选查询前** — 先读 [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md),当前 `base/v3` 通过 `view.filter update + table.records list` 组合完成筛选读取
6. **批量上限 200 条/次** — 同一表建议串行写入,并在批次间延迟 0.51 秒
7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认

View File

@@ -236,6 +236,34 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
- 需要同时授权 mail 和 im 两个域的 scope
- 分享的卡片包含邮件摘要信息,收件人可点击查看
### 发送日程邀请邮件
在邮件中嵌入日程邀请(`text/calendar`),收件人收信后可直接接受或拒绝日程。`To`/`Cc` 收件人自动成为参会人ATTENDEE发件人自动成为组织者ORGANIZER
```bash
# 发送带日程邀请的新邮件(先保存草稿,确认后发送)
lark-cli mail +send --as user \
--to alice@example.com --cc bob@example.com \
--subject '产品评审' \
--body '<p>请参加本次产品评审会议。</p>' \
--event-summary '产品评审' \
--event-start '2026-05-10T14:00+08:00' \
--event-end '2026-05-10T15:00+08:00' \
--event-location '5F 大会议室' \
--confirm-send
```
**参数说明:**
- `--event-summary`:日程标题,设置此参数即开启日程邀请模式,需同时设置 `--event-start` 和 `--event-end`
- `--event-start` / `--event-end`ISO 8601 格式时间,如 `2026-05-10T14:00+08:00`
- `--event-location`:可选,日程地点
**约束:**
- `--event-*` 与 `--send-time`(定时发送)互斥,不可同时使用
- `Bcc` 收件人不会成为日程参会人;如果邮件同时包含 Bcc 和日程,后端在发送时会拒绝该请求
读取含日程邀请的邮件时,`calendar_event` 字段包含日程详情(`method`、`summary`、`start`、`end`、`organizer`、`attendees` 等)。
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.0
description: "当需要用 lark-cli 操作飞书多维表格Base时调用适用于建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
description: "当需要用 lark-cli 操作飞书多维表格Base时调用搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -42,6 +42,7 @@ metadata:
3. 定位到命令后,先读该命令对应的 reference再执行命令。
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
6. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` 搜索 `BITABLE` 资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md):标题精确匹配、限定创建者/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
## 2. 模块与命令导航
@@ -67,6 +68,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference`--folder-token``--time-zone` 都是可选项 |
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference复制成功后应主动返回新 Base 标识信息 |
@@ -102,7 +104,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-search.md`](references/lark-base-record-search.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md)、[`lark-base-record-get.md`](references/lark-base-record-get.md) | 默认优先 `+record-list`仅当用户提供明确搜索关键词时使`+record-search`取数不用来做聚合分析;`--limit` 最大 `200`;仅在用户明确需要时继续翻页;`+record-list` 只能串行执行 |
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide已知 `record_id` `+record-get`明确关键词用 `+record-search`普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query` |
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token``+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403 |
@@ -117,7 +119,7 @@ metadata:
|------|------------------|----------------|----------|
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
@@ -295,7 +297,7 @@ lark-cli auth login --domain base
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
9. Base 场景不要改走裸 API不要切去 `lark-cli api /open-apis/bitable/v1/...`
10. 统一使用 `--base-token`,不使用旧 `--app-token`
10. 统一使用 `--base-token`
11. workflow 场景先读 schema不要凭自然语言猜 `type`
12. dashboard 场景先读 guide提到图表、看板、block 就先进入 dashboard 模块。
13. formula / lookup 场景先读 guide没读 guide 前不要直接创建或更新。

View File

@@ -22,16 +22,16 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
## 字段类型与操作符速查AI 决策用)
> `+field-list` 返回的 `type` 字段映射number数字、text文本、select单选、multi_select多选datetime(日期时间)、checkbox复选框、user人员
> 先用 `+field-list` / `+field-get` 确认字段 `type`;本节使用当前字段接口里的 canonical 类型名:`number`、`text`、`select`、`datetime`、`checkbox`、`user`。
```
文本/电话/URL/邮箱: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
数字/货币/进度: is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
单选: is, isNot, isEmpty, isNotEmpty
多选: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
日期/时间: is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
复选框: is (value: true/false)
人员/创建人/修改人: is, isNot, isEmpty, isNotEmpty
text: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
number: is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
selectmultiple=false: is, isNot, isEmpty, isNotEmpty
selectmultiple=true: is, isNot, contains, doesNotContain, isEmpty, isNotEmpty
datetime: is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty
checkbox: is (value: true/false)
user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty
```
## data_config 通用结构
@@ -148,13 +148,13 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
| 字段类型 | value 类型 | 适用操作符 | 示例 |
|----------|-----------|-----------|------|
| 文本 / 电话 / URL | string | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | `{"field_name":"姓名","operator":"contains","value":"张"}` |
| 数字 | number | is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"金额","operator":"isGreater","value":0}` |
| 单选 | string选项名 | is, isNot, isEmpty, isNotEmpty | `{"field_name":"状态","operator":"is","value":"已完成"}` |
| 多选 | string[](选多个)/ string选单个 | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | 多选传数组如 `["标签1","标签2"]`;单选传单个字符串 |
| 日期时间 / 创建时间 / 修改时间 | numberUnix 毫秒时间戳13位 | is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"创建日期","operator":"isGreater","value":1704038400000}` |
| 复选框 | boolean | is | `{"field_name":"已审核","operator":"is","value":true}` |
| 人员 / 创建人 / 修改人 | string 或 string[](用户 ID格式 `ou_xxx` | is, isNot, isEmpty, isNotEmpty | `{"field_name":"负责人","operator":"is","value":"ou_xxxxxxxxxxxxxxxx"}` |
| `text` | string | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | `{"field_name":"姓名","operator":"contains","value":"张"}` |
| `number` | number | is, isNot, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"金额","operator":"isGreater","value":0}` |
| `select` (`multiple=false`) | string选项名 | is, isNot, isEmpty, isNotEmpty | `{"field_name":"状态","operator":"is","value":"已完成"}` |
| `select` (`multiple=true`) | string[](选多个)/ string选单个 | is, isNot, contains, doesNotContain, isEmpty, isNotEmpty | 多选传数组如 `["标签1","标签2"]`;单选传单个字符串 |
| `datetime` / `created_at` / `updated_at` | numberUnix 毫秒时间戳13位 | is, isGreater, isGreaterEqual, isLess, isLessEqual, isEmpty, isNotEmpty | `{"field_name":"创建日期","operator":"isGreater","value":1704038400000}` |
| `checkbox` | boolean | is | `{"field_name":"已审核","operator":"is","value":true}` |
| `user` / `created_by` / `updated_by` | string 或 string[](用户 ID格式 `ou_xxx` | is, isNot, isEmpty, isNotEmpty | `{"field_name":"负责人","operator":"is","value":"ou_xxxxxxxxxxxxxxxx"}` |
| 所有类型(为空/不为空) | 不需要 value | isEmpty, isNotEmpty | `{"field_name":"备注","operator":"isEmpty"}` |
> `value` 类型为 `string | number | boolean | string[]`,需根据字段类型匹配正确格式
@@ -268,7 +268,7 @@ Block 的 `data_config` 字段因 `type` 不同而变化。本文档描述所有
{
"table_name": "表名",
"series": [{ "field_name": "数值字段", "rollup": "SUM" }],
"group_by": [{ "field_name": "阶段字段", "mode": "integrated" }]
"group_by": [{ "field_name": "状态字段", "mode": "integrated" }]
}
```

View File

@@ -1,6 +1,6 @@
# 飞书多维表格使用场景完整示例base
本文档提供基于 `lark-cli base ...` 的完整示例,覆盖 unified Shortcut 与当前 `base/v3` 原生 API 的常见组合方式
本文档提供基于 `lark-cli base +...` shortcut 的完整示例。
> **返回**: [SKILL.md](../SKILL.md) | **参考**: [shortcut 字段 JSON 规范](lark-base-shortcut-field-properties.md) · [CellValue 规范](lark-base-cell-value.md)
@@ -15,35 +15,37 @@ lark-cli base +table-create \
--base-token bascnXXXXXXXX \
--name "客户管理表" \
--fields '[
{"name":"客户名称","type":"text"},
{"name":"负责人","type":"user","property":{"multiple":false}},
{"name":"客户名称","type":"text","description":"主标题字段"},
{"name":"负责人","type":"user","multiple":false,"description":"用于标记客户跟进的直接负责人"},
{"name":"签约日期","type":"datetime"},
{"name":"状态","type":"single_select","property":{"options":["进行中","已完成"]}}
{"name":"状态","type":"select","multiple":false,"options":[{"name":"进行中"},{"name":"已完成"}]}
]'
```
---
## 场景 2使用原生 API 创建数据表并查看字段
## 场景 2创建数据表并查看字段
适合需要精确观察 `base/v3` 请求参数和响应结构的场景。原生 API 直接使用 `base` service
适合需要先建表、再确认字段结构的场景
### 步骤 1在已有 Base 中创建数据表
```bash
lark-cli base tables create \
--params '{"base_token":"bascnXXXXXXXX"}' \
--data '{"name":"客户管理表"}'
lark-cli base +table-create \
--base-token bascnXXXXXXXX \
--name "客户管理表"
```
### 步骤 2列出字段
```bash
lark-cli base table.fields list \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
lark-cli base +field-list \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--limit 100
```
> 提示:当前 `base/v3` 不再使用旧的 `app_token` / `app.table.*` 路径,统一改为 `base_token` + `tables` / `table.fields` / `table.records`
> 提示:Base token 统一通过 `--base-token` 传入;表 ID 统一通过 `--table-id` 传入
---
@@ -52,9 +54,10 @@ lark-cli base table.fields list \
### 新增记录
```bash
lark-cli base table.records create \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX"}' \
--data '{
lark-cli base +record-upsert \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--json '{
"客户名称":"字节跳动",
"负责人":[{"id":"ou_xxx"}],
"状态":"进行中"
@@ -64,16 +67,20 @@ lark-cli base table.records create \
### 列出记录
```bash
lark-cli base table.records list \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
lark-cli base +record-list \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--limit 100
```
### 更新记录
```bash
lark-cli base table.records patch \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}' \
--data '{
lark-cli base +record-upsert \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--record-id recXXXXXXXX \
--json '{
"状态":"已完成"
}'
```
@@ -81,22 +88,27 @@ lark-cli base table.records patch \
### 删除记录
```bash
lark-cli base table.records delete \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}'
lark-cli base +record-delete \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--record-id recXXXXXXXX \
--yes
```
---
## 场景 4配置视图筛选后按视图读取记录
当前 `base/v3` 原生 spec 没有独立 `search` 方法。需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
### 更新视图筛选条件
```bash
lark-cli base view.filter update \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX"}' \
--data '{
lark-cli base +view-set-filter \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--view-id vewXXXXXXXX \
--json '{
"logic":"and",
"conditions":[
{
@@ -111,8 +123,11 @@ lark-cli base view.filter update \
### 按视图读取记录
```bash
lark-cli base table.records list \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX","limit":100}'
lark-cli base +record-list \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--view-id vewXXXXXXXX \
--limit 100
```
---
@@ -123,8 +138,3 @@ lark-cli base table.records list \
- 需要按业务字段名做 upsert 时,优先 `lark-cli base +record-upsert`
- 需要配置筛选视图时,优先 `lark-cli base +view-set-filter`
- 需要记录历史时,优先 `lark-cli base +record-history-list`
原生 API 更适合两类场景:
- 需要逐步核对 `schema base.<resource>.<method>` 的请求参数
- 需要精确控制单次表 / 字段 / 记录 / 视图操作

View File

@@ -43,11 +43,11 @@ This is the foundation of formula logic. You must determine this before writing
- Scalars can be used directly in operations: `[Price] * [Quantity]`
- Lists cannot be used as scalars — they must be processed first: use `SUM()` for sum, `ARRAYJOIN(",")` for joining, `FIRST()`/`LAST()`/`NTH()` for single value extraction
- Link field access `[LinkField].[TargetField]` returns a list (values of the target field for all linked records)
- **LISTCOMBINE flattening rule**: When a FILTER's result column is itself a multi-value field (MultiSelect, Link, etc.), it produces a 2D array and **must** be flattened with `.LISTCOMBINE()`; for single-value fields (Number, Text, etc.) it can be omitted, but adding it is never wrong:
- **LISTCOMBINE flattening rule**: When a FILTER's result column is itself a multi-value field (`select` with `multiple=true`, `link`, etc.), it produces a 2D array and **must** be flattened with `.LISTCOMBINE()`; for single-value fields (`number`, `text`, etc.) it can be omitted, but adding it is never wrong:
```
[Table].FILTER(CurrentValue.[Field] = [Value]).[MultiSelectCol].LISTCOMBINE() ← required for multi-value columns
[Table].FILTER(CurrentValue.[Field] = [Value]).[NumberCol].LISTCOMBINE() ← optional for single-value columns
[Table].FILTER(CurrentValue.[Field] = [Value]).[Tags].LISTCOMBINE() ← required for multi-value columns
[Table].FILTER(CurrentValue.[Field] = [Value]).[NumberCol].LISTCOMBINE() ← optional for single-value columns
```
---
@@ -56,14 +56,14 @@ This is the foundation of formula logic. You must determine this before writing
### Field storage types
| Type | Description | Supported operations |
| ----------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Number | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
| Text | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
| Date | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
| MultiSelect | Data list | List functions, CONTAIN checks |
| Link | Links to other table records | Chained access `[LinkField].[Field]`, result is a list |
| Boolean | TRUE/FALSE | Logical operations; auto-converts to number when compared with numbers |
| Type | Description | Supported operations |
|------|-------------|----------------------|
| `number` | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
| `text` | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
| `datetime` | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
| `select` (`multiple=true`) | Data list | List functions, CONTAIN checks |
| `link` | Links to other table records | Chained access `[LinkField].[Field]`, result is a list |
| `checkbox` | TRUE/FALSE | Logical operations; auto-converts to number when compared with numbers |
### Implicit type conversion
@@ -81,11 +81,11 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
**Principle**: When types differ, explicitly convert one side rather than relying on implicit conversion:
- Number vs Text → use `VALUE()` to convert text to number
- Date vs Text → use `TEXT()` to convert date to text
- Date vs Date equality → Dates include time components, so direct `=` comparison may fail due to different hours/minutes/seconds. For day-level equality, convert to text first: `TEXT([DateA], "YYYY/MM/DD") = TEXT([DateB], "YYYY/MM/DD")`
- Select and User fields can be compared with both same-type values and text
- Text fields in numeric aggregation (SUM/AVERAGE/MIN/MAX etc.) → convert to number with `VALUE()` first. For FILTER results, use `.MAP(VALUE(CurrentValue)).SUM()`
- `number` vs `text` → use `VALUE()` to convert text to number
- `datetime` vs `text` → use `TEXT()` to convert date to text
- `datetime` vs `datetime` equality → dates include time components, so direct `=` comparison may fail due to different hours/minutes/seconds. For day-level equality, convert to text first: `TEXT([DateA], "YYYY/MM/DD") = TEXT([DateB], "YYYY/MM/DD")`
- `select` and `user` fields can be compared with both same-type values and text
- `text` fields in numeric aggregation (SUM/AVERAGE/MIN/MAX etc.) → convert to number with `VALUE()` first. For FILTER results, use `.MAP(VALUE(CurrentValue)).SUM()`
---
@@ -99,7 +99,7 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
| ---------------------------- | ----------------------- | --------------------------- | --------------------------------------------------------- |
| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` |
| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` |
| MultiSelect field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
| `select` (`multiple=true`) field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` |
### Key rules
@@ -190,7 +190,7 @@ Retrieves the target field values for all linked records as a list. Supports con
```
Correct: [Sales].FILTER(CurrentValue.[Amount] > 100).[Customer]
Correct: [Sales].FILTER(condition).SORTBY([Sales].[SortCol]).[Customer] ← result column at end of chain
Wrong: [Sales].FILTER(CurrentValue.[Amount] > 100) ← missing result column
Wrong: [Sales].FILTER(CurrentValue.[Amount] > 100) ← missing result column
```
- **When data range is a column** `[TableName].[Field]` or a list, FILTER returns the filtered list directly — **no** result column needed:
@@ -244,9 +244,9 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) |
| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors |
| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number |
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains the value; **does NOT do text substring matching** |
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains all specified values |
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if list/MultiSelect contains only the specified values |
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** |
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values |
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values |
| TRUE | `TRUE()` | Boolean | Returns TRUE |
| FALSE | `FALSE()` | Boolean | Returns FALSE |
| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID |
@@ -256,7 +256,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
### 8.2 Numeric functions
| Function | Signature | Return type | Description |
| ----------------------------------------------------------------- | ---------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --- | --- | --- | --- |
| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list |
| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average |
| MAX | `MAX(val1, val2, ...)` | Number | Maximum |
@@ -336,7 +336,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
### 8.5 List functions
| Function | Signature | Return type | Description |
| ----------- | ---------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --- | --- | --- | --- |
| LIST | `LIST(val1, val2, ...)` | List | Create a list |
| FIRST | `FIRST(list)` | Scalar | First element |
| LAST | `LAST(list)` | Scalar | Last element |
@@ -358,7 +358,7 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
| | CONTAIN | CONTAINTEXT |
| ----------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
| Purpose | Tests if **list/MultiSelect** contains a value | Tests if **text** contains a substring |
| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring |
| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` |
| Wrong usage | `CONTAIN([Notes], "completed")` — cannot do substring matching | `CONTAINTEXT([Tags], "Urgent")` — Tags is a list, not text |
@@ -494,7 +494,7 @@ DAYS([EndDate], [StartDate])
### Pattern 7: List element mapping
```
[MultiSelectField].MAP(CurrentValue & " tag")
[SelectField(which multiple=true)].MAP(CurrentValue & " tag")
SPLIT([TextField], ",").MAP(TRIM(CurrentValue))
```
@@ -579,13 +579,13 @@ Wrong: CONTAIN([Notes], "urgent")
Correct: CONTAINTEXT([Notes], "urgent")
```
Reason: CONTAIN checks if a list/MultiSelect contains a whole value, not substring matching. Use CONTAINTEXT for text substrings.
Reason: CONTAIN checks if a list or `select` (`multiple=true`) contains a whole value, not substring matching. Use CONTAINTEXT for text substrings.
### Mistake 9: Date concatenation without formatting
```
Not recommended: "Deadline: " & [DateField] ← output format is uncontrolled
Recommended: "Deadline: " & TEXT([DateField], "YYYY-MM-DD")
Not recommended: "Deadline: " & [DateField] ← output format is uncontrolled
Recommended: "Deadline: " & TEXT([DateField], "YYYY-MM-DD")
```
Reason: Concatenating a date with `&` won't error, but uses the default format. Use TEXT to specify the format explicitly.
@@ -649,9 +649,9 @@ IF(
**Table structure**:
- Orders: ID (AutoNumber), OrderItems (Link [target: OrderItems, foreign key: ID])
- OrderItems: ID (AutoNumber), Product (Link [target: Products, foreign key: ID])
- Products: ID (AutoNumber), ProductName (Text)
- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID])
- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID])
- Products: ID (`auto_number`), ProductName (`text`)
**Current table**: Orders

View File

@@ -43,7 +43,7 @@ POST /open-apis/base/v3/bases/:base_token/copy
- CLI 会额外标记 `copied: true`
- 回复结果时,必须主动返回新 Base 的可访问链接:
- 优先使用返回结果中的 `base.url`
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token``app_token`
- 同时返回新 Base 的 token
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
> [!IMPORTANT]

View File

@@ -38,7 +38,7 @@ POST /open-apis/base/v3/bases
- CLI 会额外标记 `created: true`
- 回复结果时,必须主动返回新 Base 的可访问链接:
- 优先使用返回结果中的 `base.url`
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token``app_token`
- 同时返回新 Base 的 token
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
> [!IMPORTANT]

View File

@@ -72,15 +72,18 @@
}
```
### 2.6 user
### 2.6 user / group_chat
用对象数组,元素至少包含 `id`单选/多选人员字段都使用数组;`id` 必须是可被当前 Base 识别的用户 ID写入前确认字段是否允许多选人员
用对象数组,元素至少包含 `id`人员字段传用户 ID`ou_xxx`),群字段传群 ID`oc_xxx`);单值/多值都统一使用数组
```json
{
"负责人": [
{ "id": "ou_xxx" },
{ "id": "ou_xxx2" }
],
"协作群": [
{ "id": "oc_xxx" }
]
}
```

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