Compare commits

..

18 Commits

Author SHA1 Message Date
liangshuo-1
f0e724cbd4 chore: cut v1.0.12 with reviewed release notes (#493)
This release prep captures the version bump and changelog entry for v1.0.12 without pulling unrelated workspace edits into the release branch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: require user confirmation for all contact search results

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

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

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

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

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

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

* fix: reject null in base JSON object parser

---------

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

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

* docs(task): document task event payload shape

* refactor(task): remove unused buildUserIDs helper

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

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

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

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

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

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

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

* docs(task): clarify tasklist search routing

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

* docs(task): refine search routing heuristics

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

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

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

View File

@@ -2,6 +2,37 @@
All notable changes to this project will be documented in this file.
## [v1.0.12] - 2026-04-15
### Features
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
- **mail**: Return recall hints for sent emails when recall is available (#481)
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
### Documentation
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
## [v1.0.11] - 2026-04-14
### Features
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
- Streamline interactive login by removing the extra auth confirmation step (#451)
### Bug Fixes
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
### Documentation
- **sheets**: Document value formats for formulas and special field types (#456)
- **readme**: Add Attendance to the features table (#460)
## [v1.0.10] - 2026-04-13
### Features
@@ -328,6 +359,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8

View File

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

View File

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

View File

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

View File

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

84
package-lock.json generated Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,7 @@ var MailDraftCreate = common.Shortcut{
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
signatureFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
input, err := parseDraftCreateInput(runtime)
@@ -72,6 +73,9 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the full email body")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
@@ -82,11 +86,15 @@ var MailDraftCreate = common.Shortcut{
if err != nil {
return err
}
rawEML, err := buildRawEMLForDraftCreate(runtime, input)
mailboxID := resolveComposeMailboxID(runtime)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
return err
}
rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult)
if err != nil {
return err
}
mailboxID := resolveComposeMailboxID(runtime)
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
@@ -121,7 +129,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er
return input, nil
}
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (string, error) {
func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) {
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
@@ -153,12 +161,18 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
var autoResolvedPaths []string
if input.PlainText {
bld = bld.TextBody([]byte(input.Body))
} else if bodyIsHTML(input.Body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body
if !bodyIsHTML(input.Body) {
htmlBody = buildBodyDiv(input.Body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return "", resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -169,6 +183,7 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
}

View File

@@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
Body: `<p>Hello <b>world</b></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
Attach: "./big.txt",
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
if err == nil {
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
}
@@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
if err == nil {
t.Fatal("expected error for orphaned --inline CID not referenced in body")
}
@@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
}
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
_, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
if err == nil {
t.Fatal("expected error for missing CID reference")
}
@@ -153,7 +153,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
PlainText: true,
}
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input)
rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}

View File

@@ -92,6 +92,24 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return output.ErrValidation("parse draft raw EML failed: %v", err)
}
// Pre-process insert_signature ops: resolve signature using the draft's
// From address so alias/shared-mailbox senders get correct template vars.
var draftFromEmail string
if len(snapshot.From) > 0 {
draftFromEmail = snapshot.From[0].Address
}
for i := range patch.Ops {
if patch.Ops[i].Op == "insert_signature" {
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
if sigErr != nil {
return sigErr
}
if sigResult != nil {
patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent
patch.Ops[i].SignatureImages = sigResult.Images
}
}
}
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
return output.ErrValidation("apply draft patch failed: %v", err)
@@ -313,6 +331,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"},
},
"supported_ops_by_group": []map[string]interface{}{
{
@@ -348,6 +368,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
},
{
"group": "signature",
"ops": []map[string]interface{}{
{"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}},
{"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"},
},
},
},
"recommended_usage": []string{
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",

View File

@@ -34,7 +34,7 @@ var MailForward = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
},
signatureFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -64,6 +64,9 @@ var MailForward = common.Shortcut{
return err
}
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -77,7 +80,12 @@ var MailForward = common.Shortcut{
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -114,7 +122,7 @@ var MailForward = common.Shortcut{
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -138,8 +146,13 @@ var MailForward = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + forwardQuote
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + forwardQuote
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -150,7 +163,7 @@ var MailForward = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
return err
}
} else {
@@ -222,10 +235,7 @@ var MailForward = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -32,7 +32,7 @@ var MailReply = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
},
signatureFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -56,6 +56,9 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -74,7 +77,12 @@ var MailReply = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -92,7 +100,7 @@ var MailReply = common.Shortcut{
}
replyTo = mergeAddrLists(replyTo, toFlag)
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -139,8 +147,13 @@ var MailReply = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + quoted
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -151,7 +164,7 @@ var MailReply = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
return err
}
} else {
@@ -185,10 +198,7 @@ var MailReply = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -33,7 +33,7 @@ var MailReplyAll = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
},
signatureFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -57,6 +57,9 @@ var MailReplyAll = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -76,7 +79,12 @@ var MailReplyAll = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return fmt.Errorf("failed to fetch original message: %w", err)
@@ -110,7 +118,7 @@ var MailReplyAll = common.Shortcut{
return err
}
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw))
useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil)
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
@@ -153,8 +161,13 @@ var MailReplyAll = common.Shortcut{
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + quoted
bodyWithSig := resolved
if sigResult != nil {
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
}
fullHTML := bodyWithSig + quoted
bld = bld.HTMLBody([]byte(fullHTML))
bld = addSignatureImagesToBuilder(bld, sigResult)
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -165,7 +178,7 @@ var MailReplyAll = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil {
return err
}
} else {
@@ -199,10 +212,7 @@ var MailReplyAll = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -32,7 +32,7 @@ var MailSend = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
},
signatureFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -62,6 +62,9 @@ var MailSend = common.Shortcut{
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -76,6 +79,13 @@ var MailSend = common.Shortcut{
confirmSend := runtime.Bool("confirm-send")
senderEmail := resolveComposeSenderEmail(runtime)
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
if err != nil {
return err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
Subject(subject).
@@ -96,12 +106,19 @@ var MailSend = common.Shortcut{
var autoResolvedPaths []string
if plainText {
bld = bld.TextBody([]byte(body))
} else if bodyIsHTML(body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.
htmlBody := body
if !bodyIsHTML(body) {
htmlBody = buildBodyDiv(body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return resolveErr
}
resolved = injectSignatureIntoBody(resolved, sigResult)
bld = bld.HTMLBody([]byte(resolved))
bld = addSignatureImagesToBuilder(bld, sigResult)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
@@ -112,6 +129,7 @@ var MailSend = common.Shortcut{
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return err
}
@@ -132,7 +150,6 @@ var MailSend = common.Shortcut{
return fmt.Errorf("failed to build EML: %w", err)
}
mailboxID := resolveComposeMailboxID(runtime)
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
@@ -149,10 +166,7 @@ var MailSend = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
runtime.Out(buildSendResult(resData, mailboxID), nil)
return nil
},
}

View File

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

View File

@@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut {
MailDraftCreate,
MailDraftEdit,
MailForward,
MailSignature,
}
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"encoding/json"
"regexp"
"strings"
)
// variableMetaProps represents the JSON structure in data-variable-meta-props attributes.
type variableMetaProps struct {
ID string `json:"id"`
Type string `json:"type"` // "text" or "image"
DisplayName string `json:"displayName"` // human-readable label
Width string `json:"width"` // image width (for type=image)
Style string `json:"style"` // CSS style
Circle bool `json:"circle"` // circular image
}
// variableSpanRe matches <span data-variable-meta-props='...'> and captures the JSON and inner content.
// Group 1: JSON attribute value (double-quoted), Group 2: (single-quoted), Group 3: inner content.
//
// Limitation: uses regex instead of DOM parsing (Go has no built-in DOMParser like JS).
// If a variable <span> contains nested <span> tags, [\s\S]*? will match to the
// innermost </span>, potentially truncating content. In practice, Lark's signature
// templates do not nest <span> inside variable spans (verified against mail-editor
// source and test data). If this becomes an issue, consider using golang.org/x/net/html.
var variableSpanRe = regexp.MustCompile(
`<span\s+data-variable-meta-props=(?:"([^"]*?)"|'([^']*?)')>([\s\S]*?)</span>`)
// InterpolateTemplate replaces template variables in a TENANT signature's content HTML.
// For USER signatures (no template variables), it returns sig.Content unchanged.
//
// Parameters:
// - sig: the signature object
// - lang: language code for i18n ("zh_cn", "en_us", "ja_jp")
// - senderName: sender display name (overrides B-NAME)
// - senderEmail: sender email address (overrides B-ENTERPRISE-EMAIL)
func InterpolateTemplate(sig *Signature, lang, senderName, senderEmail string) string {
if !sig.HasTemplateVars() {
return sig.Content
}
// Build value map from user_fields with i18n resolution.
valueMap := make(map[string]string, len(sig.UserFields)+2)
for key, field := range sig.UserFields {
valueMap[key] = field.Resolve(lang)
}
// Fixed injections override API values.
if senderName != "" {
valueMap["B-NAME"] = senderName
}
if senderEmail != "" {
valueMap["B-ENTERPRISE-EMAIL"] = senderEmail
}
// Replace each <span data-variable-meta-props='...'> with interpolated content.
result := variableSpanRe.ReplaceAllStringFunc(sig.Content, func(match string) string {
submatches := variableSpanRe.FindStringSubmatch(match)
if submatches == nil {
return match
}
// JSON is in group 1 (double-quoted) or group 2 (single-quoted).
attrJSON := submatches[1]
if attrJSON == "" {
attrJSON = submatches[2]
}
// Unescape HTML entities in the JSON attribute value.
attrJSON = unescapeHTMLEntities(attrJSON)
var meta variableMetaProps
if err := json.Unmarshal([]byte(attrJSON), &meta); err != nil {
return match // preserve original on parse failure
}
val, ok := valueMap[meta.ID]
if !ok {
val = "" // variable not in map, replace with empty
}
switch meta.Type {
case "text":
return interpolateText(val, meta.Style)
case "image":
return interpolateImage(val, meta)
default:
return val
}
})
return result
}
// interpolateText returns the replacement for a text variable.
func interpolateText(val, style string) string {
if val == "" {
return ""
}
// If value looks like a URL, wrap in <a>.
if isURL(val) {
escaped := escapeHTML(val)
return `<a href="` + escaped + `" target="_blank" rel="noopener noreferrer">` + escaped + `</a>`
}
if style != "" {
return `<span style="` + escapeHTML(style) + `">` + escapeHTML(val) + `</span>`
}
return escapeHTML(val)
}
// interpolateImage returns the replacement for an image variable.
func interpolateImage(val string, meta variableMetaProps) string {
if val == "" {
return ""
}
var attrs []string
attrs = append(attrs, `src="`+escapeHTML(val)+`"`)
if meta.Width != "" {
attrs = append(attrs, `width="`+escapeHTML(meta.Width)+`"`)
}
var styles []string
if meta.Style != "" {
styles = append(styles, meta.Style)
}
if meta.Circle {
styles = append(styles, "border-radius: 100%")
}
if len(styles) > 0 {
attrs = append(attrs, `style="`+escapeHTML(strings.Join(styles, ";"))+`"`)
}
return `<img ` + strings.Join(attrs, " ") + `>`
}
func isURL(s string) bool {
s = strings.TrimSpace(s)
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func unescapeHTMLEntities(s string) string {
s = strings.ReplaceAll(s, "&quot;", `"`)
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
return s
}

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"strings"
"testing"
)
func TestInterpolateTemplate_UserSignatureUnchanged(t *testing.T) {
sig := &Signature{
Content: "<b>My signature</b>",
SignatureType: SignatureTypeUser,
}
got := InterpolateTemplate(sig, "zh_cn", "Alice", "alice@example.com")
if got != sig.Content {
t.Errorf("USER signature should be unchanged, got %q", got)
}
}
func TestInterpolateTemplate_TenantTextVariables(t *testing.T) {
sig := &Signature{
Content: `姓名:<span data-variable-meta-props='{"id":"B-NAME","type":"text"}'>{text}</span>, 部门:<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-NAME", "B-DEPARTMENT"},
UserFields: map[string]UserFieldValue{
"B-NAME": {DefaultVal: "张三", I18nVals: map[string]string{"zh_cn": "", "en_us": "Zhang San"}},
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "研发部", "en_us": "R&D"}},
},
}
// zh_cn: B-DEPARTMENT should resolve to "研发部" (from i18n), B-NAME overridden by senderName
got := InterpolateTemplate(sig, "zh_cn", "李四", "lisi@example.com")
if !strings.Contains(got, "李四") {
t.Errorf("expected senderName override for B-NAME, got %q", got)
}
if !strings.Contains(got, "研发部") {
t.Errorf("expected zh_cn i18n value for B-DEPARTMENT, got %q", got)
}
if strings.Contains(got, "{text}") {
t.Errorf("should not contain raw placeholder {text}, got %q", got)
}
if strings.Contains(got, "data-variable-meta-props") {
t.Errorf("should not contain data-variable-meta-props attribute, got %q", got)
}
}
func TestInterpolateTemplate_I18nFallback(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-DEPARTMENT","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-DEPARTMENT"},
UserFields: map[string]UserFieldValue{
"B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "", "en_us": ""}},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, "默认部门") {
t.Errorf("expected fallback to DefaultVal, got %q", got)
}
}
func TestInterpolateTemplate_HTMLEntityEscaping(t *testing.T) {
// Simulate the HTML-entity-escaped attribute format from real API responses.
sig := &Signature{
Content: `<span data-variable-meta-props="{&quot;id&quot;:&quot;B-NAME&quot;,&quot;type&quot;:&quot;text&quot;}">{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-NAME"},
UserFields: map[string]UserFieldValue{
"B-NAME": {DefaultVal: "default"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "陈煌", "")
if !strings.Contains(got, "陈煌") {
t.Errorf("expected interpolated name, got %q", got)
}
}
func TestInterpolateTemplate_URLAsText(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-URL","type":"text"}'>{text}</span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-URL"},
UserFields: map[string]UserFieldValue{
"B-URL": {DefaultVal: "https://example.com"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, "<a href=") {
t.Errorf("expected URL to be wrapped in <a> tag, got %q", got)
}
if !strings.Contains(got, "https://example.com") {
t.Errorf("expected URL in output, got %q", got)
}
}
func TestInterpolateTemplate_ImageVariable(t *testing.T) {
sig := &Signature{
Content: `<span data-variable-meta-props='{"id":"B-LOGO","type":"image","width":"40"}'><img src="cid:old"/></span>`,
SignatureType: SignatureTypeTenant,
TemplateJSONKeys: []string{"B-LOGO"},
UserFields: map[string]UserFieldValue{
"B-LOGO": {DefaultVal: "cid:new-logo-cid"},
},
}
got := InterpolateTemplate(sig, "zh_cn", "", "")
if !strings.Contains(got, `src="cid:new-logo-cid"`) {
t.Errorf("expected new image src, got %q", got)
}
if !strings.Contains(got, `width="40"`) {
t.Errorf("expected width attribute, got %q", got)
}
}
func TestUserFieldValue_Resolve(t *testing.T) {
v := UserFieldValue{
DefaultVal: "default",
I18nVals: map[string]string{"zh_cn": "中文", "en_us": "", "ja_jp": "日本語"},
}
if got := v.Resolve("zh_cn"); got != "中文" {
t.Errorf("zh_cn = %q, want 中文", got)
}
if got := v.Resolve("en_us"); got != "default" {
t.Errorf("en_us (empty) should fallback to default, got %q", got)
}
if got := v.Resolve("ja_jp"); got != "日本語" {
t.Errorf("ja_jp = %q, want 日本語", got)
}
if got := v.Resolve("fr_fr"); got != "default" {
t.Errorf("unknown lang should fallback, got %q", got)
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
// SignatureType represents the type of a mail signature.
type SignatureType string
const (
SignatureTypeUser SignatureType = "USER"
SignatureTypeTenant SignatureType = "TENANT"
)
// SignatureDevice represents the device platform a signature is designed for.
type SignatureDevice string
const (
DevicePC SignatureDevice = "PC"
DeviceMobile SignatureDevice = "MOBILE"
)
// SignatureImage holds metadata for an inline image embedded in a signature.
type SignatureImage struct {
ImageName string `json:"image_name,omitempty"`
FileKey string `json:"file_key,omitempty"`
CID string `json:"cid,omitempty"`
FileSize string `json:"file_size,omitempty"`
Header string `json:"header,omitempty"`
ImageWidth int32 `json:"image_width,omitempty"`
ImageHeight int32 `json:"image_height,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
}
// UserFieldValue holds a template variable value with multi-language support.
type UserFieldValue struct {
DefaultVal string `json:"default_val"`
I18nVals map[string]string `json:"i18n_vals"` // keys: "zh_cn", "en_us", "ja_jp"
}
// Resolve returns the localized value for the given language code.
// Falls back to DefaultVal when the language key is missing or empty.
func (v UserFieldValue) Resolve(lang string) string {
if val, ok := v.I18nVals[lang]; ok && val != "" {
return val
}
return v.DefaultVal
}
// Signature represents a single mail signature returned by the API.
type Signature struct {
ID string `json:"id"`
Name string `json:"name"`
SignatureType SignatureType `json:"signature_type"`
SignatureDevice SignatureDevice `json:"signature_device"`
Content string `json:"content"`
Images []SignatureImage `json:"images,omitempty"`
TemplateJSONKeys []string `json:"template_json_keys,omitempty"`
UserFields map[string]UserFieldValue `json:"user_fields,omitempty"`
}
// IsTenant returns true if this is a tenant/corporate signature with template variables.
func (s *Signature) IsTenant() bool {
return s.SignatureType == SignatureTypeTenant
}
// HasTemplateVars returns true if the signature contains template variables that need interpolation.
func (s *Signature) HasTemplateVars() bool {
return len(s.TemplateJSONKeys) > 0
}
// SignatureUsage indicates which signature is used by default for a given email address.
type SignatureUsage struct {
EmailAddress string `json:"email_address"`
SendMailSignatureID string `json:"send_mail_signature_id"`
ReplySignatureID string `json:"reply_signature_id"`
}
// GetSignaturesResponse is the parsed response from the get_signatures API.
type GetSignaturesResponse struct {
Signatures []Signature `json:"signatures"`
Usages []SignatureUsage `json:"usages"`
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"encoding/json"
"fmt"
"net/url"
"github.com/larksuite/cli/shortcuts/common"
)
// processCache holds per-mailbox cached responses.
// CLI runs one command per process, so a package-level map is sufficient —
// it is naturally scoped to a single Execute lifecycle.
var processCache = map[string]*GetSignaturesResponse{}
func signaturesPath(mailboxID string) string {
return "/open-apis/mail/v1/user_mailboxes/" + url.PathEscape(mailboxID) + "/settings/signatures"
}
// ListAll fetches all signatures and usage info for a mailbox.
// Results are cached per mailboxID within the current Execute lifecycle.
func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesResponse, error) {
if cached, ok := processCache[mailboxID]; ok {
return cached, nil
}
data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil)
if err != nil {
return nil, fmt.Errorf("get signatures: %w", err)
}
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("get signatures: marshal response: %w", err)
}
var resp GetSignaturesResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("get signatures: unmarshal response: %w", err)
}
processCache[mailboxID] = &resp
return &resp, nil
}
// List returns all signatures for a mailbox.
func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error) {
resp, err := ListAll(runtime, mailboxID)
if err != nil {
return nil, err
}
return resp.Signatures, nil
}
// Get returns a single signature by ID. Returns an error if not found.
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
resp, err := ListAll(runtime, mailboxID)
if err != nil {
return nil, err
}
for i := range resp.Signatures {
if resp.Signatures[i].ID == signatureID {
return &resp.Signatures[i], nil
}
}
return nil, fmt.Errorf("signature not found: %s", signatureID)
}

View File

@@ -0,0 +1,245 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"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/signature"
)
// signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts.
var signatureFlag = common.Flag{
Name: "signature-id",
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
}
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
ID string
RenderedContent string
Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// Returns nil if signatureID is empty.
// resolveSignature fetches, interpolates, and downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
if signatureID == "" {
return nil, nil
}
sig, err := signature.Get(runtime, mailboxID, signatureID)
if err != nil {
return nil, err
}
// Resolve sender info for template interpolation.
lang := resolveLang(runtime)
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
// Download signature inline images. The file_key field contains a
// direct download URL provided by the mail backend.
var images []draftpkg.SignatureImage
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
return &signatureResult{
ID: sig.ID,
RenderedContent: rendered,
Images: images,
}, nil
}
// injectSignatureIntoBody inserts signature HTML into the body, before the quote block.
// It removes any existing signature first, then places the new signature between
// the user-authored content and the quote block (if any).
// Returns the new full HTML body.
func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string {
if sig == nil {
return bodyHTML
}
cleaned := draftpkg.RemoveSignatureHTML(bodyHTML)
userContent, quote := draftpkg.SplitAtQuote(cleaned)
sigBlock := draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sig.ID, sig.RenderedContent)
return userContent + sigBlock + quote
}
// addSignatureImagesToBuilder adds signature inline images to the EML builder.
func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) emlbuilder.Builder {
if sig == nil {
return bld
}
for _, img := range sig.Images {
cid := normalizeInlineCID(img.CID)
if cid == "" {
continue
}
bld = bld.AddInline(img.Data, img.ContentType, img.FileName, cid)
}
return bld
}
// resolveSenderInfo fetches senderName and senderEmail via the send_as API.
// resolveSenderInfo fetches send_as addresses and returns the name/email
// for signature interpolation. If fromEmail is non-empty, it matches
// that address in the sendable list (for alias/send_as scenarios);
// otherwise falls back to the first (primary) address.
func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) {
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil)
if err != nil {
return "", ""
}
addrs, ok := data["sendable_addresses"].([]interface{})
if !ok || len(addrs) == 0 {
return "", ""
}
// If fromEmail is specified, find the matching address.
if fromEmail != "" {
for _, a := range addrs {
m, ok := a.(map[string]interface{})
if !ok {
continue
}
e, _ := m["email_address"].(string)
if strings.EqualFold(e, fromEmail) {
n, _ := m["name"].(string)
return n, e
}
}
}
// Fall back to the first sendable address (primary).
first, ok := addrs[0].(map[string]interface{})
if !ok {
return "", ""
}
n, _ := first["name"].(string)
e, _ := first["email_address"].(string)
return n, e
}
// downloadSignatureImage downloads a signature image by its direct URL.
// Security: enforces https, does not send Bearer token (URL is pre-signed),
// uses context timeout, and limits response size. Aligned with
// downloadAttachmentContent in helpers.go.
func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err)
}
if u.Scheme != "https" {
return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, "", fmt.Errorf("signature image download: URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
// Do NOT send Authorization: the download URL is pre-signed.
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("signature image download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body))
}
const maxSize = 10 * 1024 * 1024
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, "", fmt.Errorf("signature image download: read body: %w", err)
}
if len(data) > maxSize {
return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit")
}
ct := resp.Header.Get("Content-Type")
if ct == "" || ct == "application/octet-stream" {
ct = contentTypeFromFilename(filename)
}
return data, ct, nil
}
func contentTypeFromFilename(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".svg":
return "image/svg+xml"
case ".bmp":
return "image/bmp"
default:
return "application/octet-stream"
}
}
// signatureCIDs returns the CID list from a signatureResult, for inline CID validation.
func signatureCIDs(sig *signatureResult) []string {
if sig == nil {
return nil
}
cids := make([]string, 0, len(sig.Images))
for _, img := range sig.Images {
cid := normalizeInlineCID(img.CID)
if cid != "" {
cids = append(cids, cid)
}
}
return cids
}
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
}
return nil
}

View File

@@ -0,0 +1,333 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func dataValidationBasePath(token string) string {
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
validate.EncodePathSegment(token))
}
func dataValidationSheetPath(token, sheetID string) string {
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
}
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
arr[i] = s
}
return arr, nil
}
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
}
}
return ranges, nil
}
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
if err != nil {
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
}
dv := map[string]interface{}{
"conditionValues": condValues,
}
opts := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("multiple") {
opts["multipleValues"] = runtime.Bool("multiple")
}
if runtime.Cmd.Flags().Changed("highlight") {
opts["highlightValidData"] = runtime.Bool("highlight")
}
if runtime.Str("colors") != "" {
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
if err != nil {
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
}
opts["colors"] = colors
}
if len(opts) > 0 {
dv["options"] = opts
}
return dv, nil
}
// SheetSetDropdown sets dropdown list validation on a range.
var SheetSetDropdown = common.Shortcut{
Service: "sheets",
Command: "+set-dropdown",
Description: "Set dropdown list on a cell range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateDropdown updates dropdown list settings for given ranges.
var SheetUpdateDropdown = common.Shortcut{
Service: "sheets",
Command: "+update-dropdown",
Description: "Update dropdown list settings",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, err := validateRangesFlag(runtime); err != nil {
return err
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
Body(map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetDropdown queries dropdown list settings for a range.
var SheetGetDropdown = common.Shortcut{
Service: "sheets",
Command: "+get-dropdown",
Description: "Get dropdown list settings for a range",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
Set("token", token).Set("range", runtime.Str("range"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
}, nil,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
var SheetDeleteDropdown = common.Shortcut{
Service: "sheets",
Command: "+delete-dropdown",
Description: "Delete dropdown list from cell ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
_, err := validateRangesFlag(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"dataValidationRanges": dvRanges,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,552 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── SetDropdown ─────────────────────────────────────────────────────────────
func TestSetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": "not-json",
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"mixed types", `["ok", 1, null]`},
{"all numbers", `[1, 2, 3]`},
{"null literal", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": tc.input,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
}
})
}
}
func TestSetDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "bad-json",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "A2:A100", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `[]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
"colors": `["#FF0000"]`,
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors length") {
t.Fatalf("expected length mismatch error, got: %v", err)
}
}
func TestSetDropdownValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": true, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation: %s", got)
}
if !strings.Contains(got, `"dataValidationType":"list"`) {
t.Fatalf("DryRun missing dataValidationType: %s", got)
}
}
func TestSetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dv, _ := body["dataValidation"].(map[string]interface{})
opts, _ := dv["options"].(map[string]interface{})
if opts["multipleValues"] != true {
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
}
if opts["highlightValidData"] != true {
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
}
}
func TestSetDropdownExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
func TestSetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── UpdateDropdown ──────────────────────────────────────────────────────────
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": "not-json", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `[]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "{not-array}",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestUpdateDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `sheet1`) {
t.Fatalf("DryRun missing sheet_id: %s", got)
}
}
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["new1","new2"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["opt1"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetDropdown ─────────────────────────────────────────────────────────────
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestGetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation path: %s", got)
}
}
func TestGetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{
map[string]interface{}{
"dataValidationType": "list",
"conditionValues": []interface{}{"opt1", "opt2"},
"ranges": []interface{}{"s1!A2:A100"},
},
},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "dataValidations") {
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
}
}
func TestGetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteDropdown ──────────────────────────────────────────────────────────
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestDeleteDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dataValidationRanges`) {
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
}
}
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"rangeResults": []interface{}{
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
},
}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "rangeResults") {
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
}
}
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dvRanges, _ := body["dataValidationRanges"].([]interface{})
if len(dvRanges) != 2 {
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
}
}
func TestDeleteDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// suppress unused import for bytes in case the test helpers already import it
var _ = (*bytes.Buffer)(nil)

View File

@@ -36,5 +36,9 @@ func Shortcuts() []common.Shortcut {
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
}
}

177
shortcuts/slides/helpers.go Normal file
View File

@@ -0,0 +1,177 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// presentationRef holds a parsed --presentation input.
//
// Slides shortcuts accept three input shapes:
// - a raw xml_presentation_id token
// - a slides URL like https://<host>/slides/<token>
// - a wiki URL like https://<host>/wiki/<token> (must resolve to obj_type=slides)
type presentationRef struct {
Kind string // "slides" | "wiki"
Token string
}
// parsePresentationRef extracts a presentation token from a token, slides URL, or wiki URL.
// Wiki tokens are returned unresolved; callers must run resolveWikiToSlidesToken to
// obtain the real xml_presentation_id and verify obj_type=slides.
func parsePresentationRef(input string) (presentationRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
}
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
// appear as a prefix of the URL path. Substring matching previously let
// e.g. `https://x/docx/foo?next=/slides/abc` resolve to token "abc".
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
return presentationRef{Kind: "slides", Token: token}, nil
}
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
return presentationRef{Kind: "wiki", Token: token}, nil
}
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
// Non-URL input must be a bare token — anything with path/query/fragment
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
// get silently accepted.
if strings.ContainsAny(raw, "/?#") {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
return presentationRef{Kind: "slides", Token: raw}, nil
}
// tokenAfterPathPrefix extracts the first path segment after prefix from path.
// Returns ("", false) if path doesn't start with prefix or the segment is empty.
func tokenAfterPathPrefix(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
// resolvePresentationID resolves a parsed ref into an xml_presentation_id.
// Slides refs pass through; wiki refs are looked up via wiki.spaces.get_node and
// must resolve to obj_type=slides.
func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef) (string, error) {
switch ref.Kind {
case "slides":
return ref.Token, nil
case "wiki":
data, err := runtime.CallAPI(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return "", err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
}
if objType != "slides" {
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
}
return objToken, nil
default:
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
}
}
// imgSrcPlaceholderRegex matches `src="@<path>"` or `src='@<path>'` inside <img> tags.
// The "@" prefix is the magic marker for "this is a local file path; upload it and
// replace with file_token".
//
// Match groups:
//
// 1: opening quote character (so we can replace symmetrically)
// 2: the path string (everything inside the quotes after the leading @)
//
// We deliberately scope to <img ... src="@..."> rather than any src= so other
// schema elements (like icon/iconType) aren't accidentally rewritten.
// `\s*=\s*` tolerates `src = "..."` style attributes (XML allows whitespace
// around `=`); without it we'd silently leave such placeholders unrewritten.
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
// referenced via <img src="@path"> in the given slide XML strings.
//
// Order is preserved (first occurrence wins) so dry-run / progress messages are
// stable across runs.
func extractImagePlaceholderPaths(slideXMLs []string) []string {
var paths []string
seen := map[string]bool{}
for _, xml := range slideXMLs {
matches := imgSrcPlaceholderRegex.FindAllStringSubmatch(xml, -1)
for _, m := range matches {
if m[1] != m[3] {
// Mismatched opening/closing quotes — Go's RE2 has no backreferences,
// so we filter it here. Treat as malformed XML and skip.
continue
}
path := strings.TrimSpace(m[2])
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
// XML by looking up each path in tokens. Paths missing from the map are left
// untouched (callers should ensure the map is complete).
func replaceImagePlaceholders(slideXML string, tokens map[string]string) string {
return imgSrcPlaceholderRegex.ReplaceAllStringFunc(slideXML, func(match string) string {
sub := imgSrcPlaceholderRegex.FindStringSubmatch(match)
if len(sub) < 4 {
return match
}
quote, path, closeQuote := sub[1], sub[2], sub[3]
if quote != closeQuote {
// Mismatched quotes — see extractImagePlaceholderPaths.
return match
}
token, ok := tokens[strings.TrimSpace(path)]
if !ok {
return match
}
// Replace only the `"@<path>"` segment (quotes inclusive) so any
// surrounding attrs and whitespace around `=` stay intact. Looking up
// by the literal `@<path>"` (with closing quote) avoids accidentally
// matching the same path elsewhere in the tag.
oldQuoted := fmt.Sprintf("%s@%s%s", quote, path, closeQuote)
newQuoted := fmt.Sprintf("%s%s%s", quote, token, closeQuote)
return strings.Replace(match, oldQuoted, newQuoted, 1)
})
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"reflect"
"strings"
"testing"
)
func TestParsePresentationRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantKind string
wantToken string
wantErr string
}{
{name: "raw token", input: "slidesXXXXXXXXXXXXXXXXXXXXXX", wantKind: "slides", wantToken: "slidesXXXXXXXXXXXXXXXXXXXXXX"},
{name: "slides URL", input: "https://x.feishu.cn/slides/abc123", wantKind: "slides", wantToken: "abc123"},
{name: "slides URL with query", input: "https://x.feishu.cn/slides/abc123?from=share", wantKind: "slides", wantToken: "abc123"},
{name: "slides URL with anchor", input: "https://x.feishu.cn/slides/abc123#p1", wantKind: "slides", wantToken: "abc123"},
{name: "wiki URL", input: "https://x.feishu.cn/wiki/wikcn123", wantKind: "wiki", wantToken: "wikcn123"},
{name: "trims whitespace", input: " abc123 ", wantKind: "slides", wantToken: "abc123"},
{name: "empty", input: "", wantErr: "cannot be empty"},
{name: "blank", input: " ", wantErr: "cannot be empty"},
{name: "unsupported url", input: "https://x.feishu.cn/docx/foo", wantErr: "unsupported"},
{name: "unsupported path", input: "foo/bar", wantErr: "unsupported"},
// Regression: /slides/ inside a query string must NOT be treated as a slides marker.
{name: "slides marker inside query", input: "https://x.feishu.cn/docx/foo?next=/slides/abc", wantErr: "unsupported"},
// Regression: /wiki/ as a path segment but not a prefix must not match.
{name: "wiki marker mid-path", input: "https://x.feishu.cn/docx/wiki/wikcn123", wantErr: "unsupported"},
// Regression: bare relative path containing wiki/ is not a wiki ref.
{name: "non-url wiki segment", input: "tmp/wiki/wikcn123", wantErr: "unsupported"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parsePresentationRef(tt.input)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want substring %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Kind != tt.wantKind || got.Token != tt.wantToken {
t.Fatalf("got = %+v, want kind=%s token=%s", got, tt.wantKind, tt.wantToken)
}
})
}
}
func TestExtractImagePlaceholderPaths(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in []string
want []string
}{
{
name: "no placeholders",
in: []string{`<slide><data><img src="https://x.com/a.png"/></data></slide>`},
want: nil,
},
{
name: "single placeholder",
in: []string{`<slide><data><img src="@./pic.png" topLeftX="10"/></data></slide>`},
want: []string{"./pic.png"},
},
{
name: "single quotes",
in: []string{`<img src='@./a.png'/>`},
want: []string{"./a.png"},
},
{
name: "dedup across slides",
in: []string{
`<slide><data><img src="@./shared.png"/></data></slide>`,
`<slide><data><img src="@./shared.png" topLeftX="100"/><img src="@./other.png"/></data></slide>`,
},
want: []string{"./shared.png", "./other.png"},
},
{
name: "ignores non-img src",
in: []string{`<icon src="@./fake.png"/><img src="@./real.png"/>`},
want: []string{"./real.png"},
},
{
name: "preserves order of first occurrence",
in: []string{`<img src="@b.png"/><img src="@a.png"/><img src="@b.png"/>`},
want: []string{"b.png", "a.png"},
},
{
// Regression: Go RE2 has no backreferences, so the regex captures
// opening and closing quotes independently. Mismatched pairs must
// be filtered out post-match instead of producing bogus paths.
name: "rejects mismatched quotes",
in: []string{`<img src="@./oops.png'/>`},
want: nil,
},
{
// Regression: XML allows whitespace around `=`; placeholders in
// `src = "@..."` form must still be detected.
name: "tolerates whitespace around equals",
in: []string{`<img src = "@./spaced.png" />`},
want: []string{"./spaced.png"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := extractImagePlaceholderPaths(tt.in)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
}
}
func TestReplaceImagePlaceholders(t *testing.T) {
t.Parallel()
tokens := map[string]string{
"./pic.png": "tok_abc",
"./b.png": "tok_b",
}
tests := []struct {
name string
in string
want string
}{
{
name: "single replacement preserves siblings",
in: `<img src="@./pic.png" topLeftX="10" width="100"/>`,
want: `<img src="tok_abc" topLeftX="10" width="100"/>`,
},
{
name: "multiple replacements",
in: `<img src="@./pic.png"/><img src="@./b.png"/>`,
want: `<img src="tok_abc"/><img src="tok_b"/>`,
},
{
name: "single quotes",
in: `<img src='@./pic.png'/>`,
want: `<img src='tok_abc'/>`,
},
{
name: "leaves unknown placeholder untouched",
in: `<img src="@./missing.png"/>`,
want: `<img src="@./missing.png"/>`,
},
{
name: "leaves http url alone",
in: `<img src="https://x.com/a.png"/>`,
want: `<img src="https://x.com/a.png"/>`,
},
{
name: "leaves bare token alone",
in: `<img src="existing_token"/>`,
want: `<img src="existing_token"/>`,
},
{
// Regression: placeholders with whitespace around `=` must be
// rewritten too (XML permits the form). Surrounding whitespace
// is preserved so the rewritten attribute reads naturally.
name: "tolerates whitespace around equals",
in: `<img src = "@./pic.png" topLeftX="10"/>`,
want: `<img src = "tok_abc" topLeftX="10"/>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := replaceImagePlaceholders(tt.in, tokens)
if got != tt.want {
t.Fatalf("got %q\nwant %q", got, tt.want)
}
})
}
}

View File

@@ -9,5 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
}
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -27,10 +28,15 @@ var SlidesCreate = common.Shortcut{
Description: "Create a Lark Slides presentation",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"},
// docs:document.media:upload is required by the @-placeholder upload path.
// Declared up-front (matching the convention used by other multi-API shortcuts
// like wiki_move) so the pre-flight check fails fast and lark-cli's
// auth login --scope hint guides the user, instead of leaving an orphaned
// empty presentation when the in-flight upload 403s.
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create). <img src=\"@./local.png\"> placeholders are auto-uploaded and replaced with file_token."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if slidesStr := runtime.Str("slides"); slidesStr != "" {
@@ -41,6 +47,21 @@ var SlidesCreate = common.Shortcut{
if len(slides) > maxSlidesPerCreate {
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
}
// Validate placeholder paths up front so we don't create a presentation
// only to fail mid-way on a missing local file.
for _, path := range extractImagePlaceholderPaths(slides) {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return common.FlagErrorf("--slides @%s: must be a regular file", path)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
path, common.FormatSize(stat.Size()))
}
}
}
return nil
},
@@ -61,16 +82,32 @@ var SlidesCreate = common.Shortcut{
var slides []string
_ = json.Unmarshal([]byte(slidesStr), &slides)
n := len(slides)
total := n + 1
placeholders := extractImagePlaceholderPaths(slides)
total := n + 1 + len(placeholders)
dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)).
descSuffix := ""
if len(placeholders) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(placeholders))
}
dry.Desc(fmt.Sprintf("Create presentation%s + add %d slide(s)", descSuffix, n)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(createBody)
// Upload steps come right after creation so they can use the new
// presentation_id as parent_node.
for i, path := range placeholders {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
}
slideStepStart := 2 + len(placeholders)
slideDescSuffix := ""
if len(placeholders) > 0 {
slideDescSuffix = " (img placeholders auto-replaced)"
}
for i, slideXML := range slides {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)).
Desc(fmt.Sprintf("[%d/%d] Add slide %d%s", slideStepStart+i, total, i+1, slideDescSuffix)).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
})
@@ -121,6 +158,23 @@ var SlidesCreate = common.Shortcut{
_ = json.Unmarshal([]byte(slidesStr), &slides) // already validated
if len(slides) > 0 {
// Step 1.5: Upload any @path placeholders, then rewrite slide XML
// with the resulting file_tokens. Uploads run after creation so
// they can use the new presentation_id as parent_node.
placeholders := extractImagePlaceholderPaths(slides)
if len(placeholders) > 0 {
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
}
for i := range slides {
slides[i] = replaceImagePlaceholders(slides[i], tokens)
}
result["images_uploaded"] = uploaded
}
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
@@ -205,6 +259,33 @@ func buildPresentationXML(title string) string {
)
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can
// surface progress in the failure message.
func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID string, paths []string) (map[string]string, int, error) {
tokens := make(map[string]string, len(paths))
for i, path := range paths {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
}
fileName := filepath.Base(path)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
i+1, len(paths), fileName, common.FormatSize(stat.Size()))
token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID)
if err != nil {
return tokens, i, fmt.Errorf("@%s: %w", path, err)
}
tokens[path] = token
}
return tokens, len(paths), nil
}
// xmlEscape escapes special XML characters in text content.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")

View File

@@ -6,6 +6,7 @@ package slides
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
@@ -651,3 +652,175 @@ func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]i
}
return data
}
// TestSlidesCreateWithImagePlaceholders verifies @path placeholders are uploaded
// once each (with dedup) and replaced with file_tokens before slide.create runs.
//
// Not parallel: uses os.Chdir to pin local file paths to a temp dir.
func TestSlidesCreateWithImagePlaceholders(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("a.png", []byte("aa"), 0o644); err != nil {
t.Fatalf("write a.png: %v", err)
}
if err := os.WriteFile("b.png", []byte("bb"), 0o644); err != nil {
t.Fatalf("write b.png: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_img",
"revision_id": 1,
},
},
})
// Two distinct images → two upload calls. a.png is referenced twice but
// must be uploaded only once.
uploadStubA := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_a"}},
}
uploadStubB := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_b"}},
}
reg.Register(uploadStubA)
reg.Register(uploadStubB)
// Slide stubs: capture the rewritten slide content to assert tokens were
// actually substituted into the XML.
slideStub1 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s1", "revision_id": 2}},
}
slideStub2 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_img/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s2", "revision_id": 3}},
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img")
slidesJSON := `[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"10\"/><img src=\"@b.png\" topLeftX=\"20\"/></data></slide>",
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"30\"/></data></slide>"
]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Img test",
"--slides", slidesJSON,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["images_uploaded"] != float64(2) {
t.Fatalf("images_uploaded = %v, want 2 (a.png deduped)", data["images_uploaded"])
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
// Assert each slide.create body uses tokens (not @path placeholders), and
// that both upload tokens reach at least one slide so a buggy mapping
// where `@b.png` got rewritten to `tok_a` would still fail.
hasTokB := false
for _, stub := range []*httpmock.Stub{slideStub1, slideStub2} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
slide, _ := body["slide"].(map[string]interface{})
content, _ := slide["content"].(string)
if strings.Contains(content, "@a.png") || strings.Contains(content, "@b.png") {
t.Fatalf("slide content still contains placeholder: %s", content)
}
if !strings.Contains(content, "tok_a") {
t.Fatalf("slide content missing tok_a: %s", content)
}
if strings.Contains(content, "tok_b") {
hasTokB = true
}
}
if !hasTokB {
t.Fatal("expected at least one slide body to contain tok_b")
}
}
// TestSlidesCreatePlaceholderFileMissing verifies validation rejects a missing local file
// up front, before the presentation is created.
func TestSlidesCreatePlaceholderFileMissing(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
// No HTTP mocks registered — Validate must reject before any API call.
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
slidesJSON := `["<slide><data><img src=\"@./missing.png\"/></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "missing img",
"--slides", slidesJSON,
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for missing placeholder file")
}
if !strings.Contains(err.Error(), "missing.png") {
t.Fatalf("err = %v, want mention of missing.png", err)
}
}
// TestSlidesCreateWithPlaceholdersDryRun verifies dry-run lists upload steps
// with placeholder files counted into the total.
func TestSlidesCreateWithPlaceholdersDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("p1.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile("p2.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
slidesJSON := `["<slide><data><img src=\"@p1.png\"/><img src=\"@p2.png\"/></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "dry imgs",
"--slides", slidesJSON,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Bookend step markers: [1/4] = create presentation, [4/4] = add slide 1.
// Upload steps in between use the helper's own [N] labels (no /total).
for _, marker := range []string{"[1/4]", "[4/4]"} {
if !strings.Contains(out, marker) {
t.Fatalf("dry-run missing %s, got: %s", marker, out)
}
}
if strings.Count(out, "upload_all") != 2 {
t.Fatalf("dry-run should contain 2 upload_all calls, got: %s", out)
}
if !strings.Contains(out, slidesMediaParentType) {
t.Fatalf("dry-run missing parent_type %q, got: %s", slidesMediaParentType, out)
}
if !strings.Contains(out, "Create presentation + upload 2 image(s)") {
t.Fatalf("dry-run header should describe upload count, got: %s", out)
}
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"path/filepath"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// slidesMediaParentType is the only parent_type the slides backend accepts for
// media uploaded against an xml_presentation. Verified empirically:
// `slide_image` returns 1061001 unknown error, `slides_image` / `slides_file`
// return 1061002 params error, but `slide_file` returns a valid file_token
// that can be used as <img src="..."> in slide XML.
//
// NOTE: `slide_file` is only accepted by the single-part upload_all endpoint.
// The multipart upload_prepare endpoint rejects it (99992402 field validation
// failed), so slides image uploads are capped at 20 MB.
const slidesMediaParentType = "slide_file"
// SlidesMediaUpload uploads a local image to drive media against a slides
// presentation and returns the file_token. The token can be used as the value
// of <img src="..."> in slide XML.
//
// This is the atomic building block for getting a local image into a slides
// deck. Higher-level shortcuts (e.g. +create with @path placeholders) reuse
// the same upload helpers.
var SlidesMediaUpload = common.Shortcut{
Service: "slides",
Command: "+media-upload",
Description: "Upload a local image to a slides presentation and return the file_token (use as <img src=...>)",
Risk: "write",
// wiki:node:read is required by the wiki-URL resolution path. Declared
// up-front (matching the convention used by other multi-API shortcuts) so
// users without it get the standard auth login --scope hint at pre-flight.
Scopes: []string{"docs:document.media:upload", "wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local image path (max 20 MB)", Required: true},
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
filePath := runtime.Str("file")
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
parentNode := ref.Token
stepBase := 1
if ref.Kind == "wiki" {
parentNode = "<resolved_slides_token>"
stepBase = 2
dry.Desc("2-step orchestration: resolve wiki → upload media").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc("Upload local file to slides presentation")
}
appendSlidesUploadDryRun(dry, filePath, parentNode, stepBase)
return dry.Set("presentation_id", ref.Token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size()))
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> presentation %s\n",
fileName, common.FormatSize(stat.Size()), common.MaskToken(presentationID))
fileToken, err := uploadSlidesMedia(runtime, filePath, fileName, stat.Size(), presentationID)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": stat.Size(),
"presentation_id": presentationID,
}, nil)
return nil
},
}
// uploadSlidesMedia is the shared upload helper used by both +media-upload and
// the +create placeholder pipeline. Always uses parent_type=slide_file with the
// presentation_id as parent_node — verified to be the only working combo.
//
// Callers must ensure fileSize ≤ MaxDriveMediaUploadSinglePartSize (20 MB)
// because the multipart upload API does not accept parent_type=slide_file.
func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) {
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
fileName, common.FormatSize(fileSize))
}
parent := presentationID
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: slidesMediaParentType,
ParentNode: &parent,
})
}
// appendSlidesUploadDryRun renders the upload_all step for a single file.
func appendSlidesUploadDryRun(d *common.DryRunAPI, filePath, parentNode string, step int) {
d.POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("[%d] Upload local file (max 20 MB)", step)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": slidesMediaParentType,
"parent_node": parentNode,
"size": "<file_size>",
"file": "@" + filePath,
})
}

View File

@@ -0,0 +1,359 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestSlidesMediaUploadBasic verifies the happy path: token + presentation_id
// with a real (small) local file.
func TestSlidesMediaUploadBasic(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_tok_xyz"},
},
}
reg.Register(uploadStub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "img.png",
"--presentation", "pres_abc",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["file_token"] != "file_tok_xyz" {
t.Fatalf("file_token = %v, want file_tok_xyz", data["file_token"])
}
if data["presentation_id"] != "pres_abc" {
t.Fatalf("presentation_id = %v, want pres_abc", data["presentation_id"])
}
if data["file_name"] != "img.png" {
t.Fatalf("file_name = %v, want img.png", data["file_name"])
}
if data["size"] != float64(len("png-bytes")) {
t.Fatalf("size = %v, want %d", data["size"], len("png-bytes"))
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_type"]; got != slidesMediaParentType {
t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType)
}
if got := body.Fields["parent_node"]; got != "pres_abc" {
t.Fatalf("parent_node = %q, want pres_abc", got)
}
if got := body.Fields["file_name"]; got != "img.png" {
t.Fatalf("file_name = %q, want img.png", got)
}
}
// TestSlidesMediaUploadFromSlidesURL verifies that a slides URL is accepted
// and the path-segment token is used as parent_node.
func TestSlidesMediaUploadFromSlidesURL(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("p.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "p.png",
"--presentation", "https://x.feishu.cn/slides/url_token_123?from=share",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeMultipartBody(t, stub)
if got := body.Fields["parent_node"]; got != "url_token_123" {
t.Fatalf("parent_node = %q, want url_token_123", got)
}
data := decodeShortcutData(t, stdout)
if data["presentation_id"] != "url_token_123" {
t.Fatalf("presentation_id = %v, want url_token_123", data["presentation_id"])
}
}
// TestSlidesMediaUploadFromWikiURL verifies wiki URL → get_node lookup is performed
// and the resolved obj_token is used as parent_node.
func TestSlidesMediaUploadFromWikiURL(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "real_pres_id",
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok"}},
}
reg.Register(uploadStub)
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "w.png",
"--presentation", "https://x.feishu.cn/wiki/wikcn_xyz",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeMultipartBody(t, uploadStub)
if got := body.Fields["parent_node"]; got != "real_pres_id" {
t.Fatalf("parent_node = %q, want real_pres_id", got)
}
}
// TestSlidesMediaUploadWikiWrongType verifies wiki resolution rejects non-slides docs.
func TestSlidesMediaUploadWikiWrongType(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("w.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docx_tok",
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "w.png",
"--presentation", "https://x.feishu.cn/wiki/wikcn",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for non-slides wiki node")
}
if !strings.Contains(err.Error(), "docx") {
t.Fatalf("err = %v, want mention of resolved obj_type", err)
}
}
// TestSlidesMediaUploadFileNotFound verifies a missing local file fails fast.
func TestSlidesMediaUploadFileNotFound(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "missing.png",
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
t.Fatalf("err = %v, want file-not-found error", err)
}
}
// TestSlidesMediaUploadInvalidPresentation verifies validation rejects a bad ref.
func TestSlidesMediaUploadInvalidPresentation(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "any.png",
"--presentation", "https://x.feishu.cn/docx/foo",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for unsupported presentation URL")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("err = %v, want 'unsupported' mention", err)
}
}
// TestSlidesMediaUploadDryRun verifies dry-run prints the upload step.
func TestSlidesMediaUploadDryRun(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("dry.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "dry.png",
"--presentation", "pres_abc",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
t.Fatalf("dry-run should mention upload_all, got: %s", out)
}
if !strings.Contains(out, slidesMediaParentType) {
t.Fatalf("dry-run should mention parent_type %q, got: %s", slidesMediaParentType, out)
}
}
// runSlidesShortcut mounts and executes a slides shortcut with the given args.
func runSlidesShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, sc common.Shortcut, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
sc.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// decodeShortcutData parses the JSON envelope and returns the data map.
func decodeShortcutData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data: %#v", envelope)
}
return data
}
// withSlidesTestWorkingDir chdirs to dir for this test (restored on cleanup).
// Not compatible with t.Parallel — chdir is process-wide.
func withSlidesTestWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(cwd)
})
}
type capturedMultipart struct {
Fields map[string]string
Files map[string][]byte
}
func decodeMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
for {
part, err := reader.NextPart()
if err != nil {
break
}
data := readAll(t, part)
if part.FileName() != "" {
body.Files[part.FormName()] = data
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}
func readAll(t *testing.T, r interface {
Read(p []byte) (n int, err error)
}) []byte {
t.Helper()
var buf bytes.Buffer
tmp := make([]byte, 4096)
for {
n, err := r.Read(tmp)
if n > 0 {
buf.Write(tmp[:n])
}
if err != nil {
break
}
}
return buf.Bytes()
}

View File

@@ -223,6 +223,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
CreateTask,
UpdateTask,
SetAncestorTask,
CommentTask,
CompleteTask,
ReopenTask,
@@ -230,7 +231,11 @@ func Shortcuts() []common.Shortcut {
FollowersTask,
ReminderTask,
GetMyTasks,
GetRelatedTasks,
SearchTask,
SubscribeTaskEvent,
CreateTasklist,
SearchTasklist,
AddTaskToTasklist,
MembersTasklist,
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
relatedTasksDefaultPageLimit = 20
relatedTasksMaxPageLimit = 40
relatedTasksPageSize = 100
)
var GetRelatedTasks = common.Shortcut{
Service: "task",
Command: "+get-related-tasks",
Description: "list tasks related to me",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
params["completed"] = false
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return common.NewDryRunAPI().
GET("/open-apis/task/v2/task_v2/list_related_task").
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
queryParams.Set("completed", "false")
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = relatedTasksDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = relatedTasksMaxPageLimit
}
if pageLimit > relatedTasksMaxPageLimit {
pageLimit = relatedTasksMaxPageLimit
}
var allItems []interface{}
var lastPageToken string
var lastHasMore bool
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list related tasks")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
queryParams.Set("page_token", lastPageToken)
}
userOpenID := runtime.UserOpenId()
filtered := make([]map[string]interface{}, 0, len(allItems))
for _, item := range allItems {
task, ok := item.(map[string]interface{})
if !ok {
continue
}
if runtime.Bool("created-by-me") {
creator, _ := task["creator"].(map[string]interface{})
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
continue
}
}
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
continue
}
filtered = append(filtered, outputRelatedTask(task))
}
outData := map[string]interface{}{
"items": filtered,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
if len(filtered) == 0 {
fmt.Fprintln(w, "No related tasks found.")
return
}
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
})
return nil
},
}
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
members, _ := task["members"].([]interface{})
for _, member := range members {
memberObj, _ := member.(map[string]interface{})
role, _ := memberObj["role"].(string)
id, _ := memberObj["id"].(string)
if strings.EqualFold(role, "follower") && id == userOpenID {
return true
}
}
return false
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestTaskFollowedBy(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
userOpenID string
want bool
}{
{
name: "contains follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
map[string]interface{}{"id": "ou_2", "role": "follower"},
},
},
userOpenID: "ou_2",
want: true,
},
{
name: "missing follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
},
},
userOpenID: "ou_3",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := taskFollowedBy(tt.task, tt.userOpenID)
if got != tt.want {
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetRelatedTasks_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "with page token and incomplete filter",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("include-complete", "false")
_ = cmd.Flags().Set("page-token", "pt_001")
},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
},
{
name: "default query params",
setup: func(cmd *cobra.Command) {},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Bool("include-complete", true, "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
out := GetRelatedTasks.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestGetRelatedTasks_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json created by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "done",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123",
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
},
},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
},
{
name: "pretty pagination followed by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_2",
"items": []interface{}{
map[string]interface{}{
"guid": "task-1",
"summary": "Task One",
"url": "https://example.com/task-1",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "page_token=pt_2",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-2",
"summary": "Task Two",
"url": "https://example.com/task-2",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
},
wantParts: []string{"Task One", "Task Two"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := GetRelatedTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"strconv"
"strings"
"time"
)
func splitAndTrimCSV(input string) []string {
parts := strings.Split(input, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startMillis, endMillis string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
}
hasStart = true
startMillis = startSec + "000"
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
}
hasEnd = true
endMillis = endSec + "000"
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
}
return startMillis, endMillis, nil
}
func parseTimeRangeRFC3339(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startTime, endTime string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
}
hasStart = true
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
}
hasEnd = true
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
}
return startTime, endTime, nil
}
func formatTaskDateTimeMillis(msStr string) string {
if msStr == "" || msStr == "0" {
return ""
}
ms, err := strconv.ParseInt(msStr, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(ms).Local().Format(time.DateTime)
}
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"url": urlVal,
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
out["updated_at"] = updated
}
}
if dueObj, ok := task["due"].(map[string]interface{}); ok {
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
out["due_at"] = dueAt
}
}
}
return out
}
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"description": task["description"],
"status": task["status"],
"source": task["source"],
"mode": task["mode"],
"subtask_count": task["subtask_count"],
"tasklists": task["tasklists"],
"url": urlVal,
}
if creator, ok := task["creator"].(map[string]interface{}); ok {
out["creator"] = creator
}
if members, ok := task["members"].([]interface{}); ok {
out["members"] = members
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
return out
}
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
timeRange := map[string]interface{}{}
if start != "" {
timeRange["start_time"] = start
}
if end != "" {
timeRange["end_time"] = end
}
if len(timeRange) == 0 {
return nil
}
return map[string]interface{}{key: timeRange}
}
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
for k, v := range src {
dst[k] = v
}
}
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
if strings.TrimSpace(query) != "" {
return nil
}
if len(filter) > 0 {
return nil
}
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
}
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
var b strings.Builder
for i, item := range items {
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
if status, _ := item["status"].(string); status != "" {
fmt.Fprintf(&b, " Status: %s\n", status)
}
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(&b, " Created: %s\n", created)
}
if completed, _ := item["completed_at"].(string); completed != "" {
fmt.Fprintf(&b, " Completed: %s\n", completed)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(&b, " URL: %s\n", urlVal)
}
b.WriteString("\n")
}
if hasMore && pageToken != "" {
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
}
return b.String()
}

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
)
func TestSplitAndTrimCSV(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
{name: "empty input", input: "", want: []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitAndTrimCSV(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
}{
{
name: "with timestamps and due",
task: map[string]interface{}{
"guid": "task-123",
"summary": "summary",
"url": "https://example.com/task-123&suite_entity_num=t1",
"created_at": "1775174400000",
"due": map[string]interface{}{
"timestamp": "1775174400000",
},
},
},
{
name: "with completed and updated",
task: map[string]interface{}{
"guid": "task-456",
"summary": "done",
"url": "https://example.com/task-456",
"completed_at": "1775174400000",
"updated_at": "1775174400000",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := outputTaskSummary(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected summary output: %#v", got)
}
if got["url"] == "" {
t.Fatalf("expected url in output, got %#v", got)
}
})
}
}
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run("parse:"+tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeMillis(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
}
if tt.wantStart == "" && start != "" {
t.Fatalf("start = %q, want empty", start)
}
if tt.wantEnd == "" && end != "" {
t.Fatalf("end = %q, want empty", end)
}
if tt.wantStart == "non-empty" && start == "" {
t.Fatalf("start should not be empty")
}
if tt.wantEnd == "non-empty" && end == "" {
t.Fatalf("end should not be empty")
}
})
}
filterTests := []struct {
name string
query string
filter map[string]interface{}
wantErr bool
}{
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
}
for _, tt := range filterTests {
t.Run("filter:"+tt.name, func(t *testing.T) {
err := requireSearchFilter(tt.query, tt.filter, "search")
if tt.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
outputTests := []struct {
name string
task map[string]interface{}
}{
{
name: "full related task",
task: map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "todo",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123&suite_entity_num=t1",
"creator": map[string]interface{}{"id": "ou_1"},
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
"created_at": "1775174400000",
"completed_at": "1775174400000",
},
},
{
name: "minimal related task",
task: map[string]interface{}{
"guid": "task-456",
"summary": "Minimal",
"url": "https://example.com/task-456",
},
},
}
for _, tt := range outputTests {
t.Run("output:"+tt.name, func(t *testing.T) {
got := outputRelatedTask(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected related task output: %#v", got)
}
})
}
rangeTests := []struct {
name string
start string
end string
wantNil bool
}{
{name: "empty range", start: "", end: "", wantNil: true},
{name: "full range", start: "1", end: "2", wantNil: false},
}
for _, tt := range rangeTests {
t.Run("range:"+tt.name, func(t *testing.T) {
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
if tt.wantNil && got != nil {
t.Fatalf("expected nil, got %#v", got)
}
if !tt.wantNil && got == nil {
t.Fatalf("expected range filter, got nil")
}
})
}
}
func TestRenderRelatedTasksPretty(t *testing.T) {
tests := []struct {
name string
items []map[string]interface{}
hasMore bool
pageToken string
wantParts []string
}{
{
name: "includes next token",
items: []map[string]interface{}{
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
},
hasMore: true,
pageToken: "pt_123",
wantParts: []string{"Related Task", "Next page token: pt_123"},
},
{
name: "without next token",
items: []map[string]interface{}{
{"guid": "task-456", "summary": "Another Task"},
},
hasMore: false,
pageToken: "",
wantParts: []string{"Another Task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeRFC3339(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
}
if tt.wantStart == "rfc3339" {
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
t.Fatalf("expected RFC3339 start, got %q", start)
}
} else if start != tt.wantStart {
t.Fatalf("unexpected start: %q", start)
}
if tt.wantEnd == "rfc3339" {
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
t.Fatalf("expected RFC3339 end, got %q", end)
}
} else if end != tt.wantEnd {
t.Fatalf("unexpected end: %q", end)
}
})
}
})
}
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
taskSearchDefaultPageLimit = 20
taskSearchMaxPageLimit = 40
)
var SearchTask = common.Shortcut{
Service: "task",
Command: "+search",
Description: "search tasks",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
{Name: "follower", Desc: "follower open_ids, comma-separated"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTaskSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = taskSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = taskSearchMaxPageLimit
}
if pageLimit > taskSearchMaxPageLimit {
pageLimit = taskSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasks")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
taskID, _ := itemMap["id"].(string)
if taskID == "" {
continue
}
task, err := getTaskDetail(runtime, taskID)
if err != nil {
metaData, _ := itemMap["meta_data"].(map[string]interface{})
appLink, _ := metaData["app_link"].(string)
enriched = append(enriched, map[string]interface{}{
"guid": taskID,
"url": truncateTaskURL(appLink),
})
continue
}
enriched = append(enriched, outputTaskSummary(task))
}
outData := map[string]interface{}{
"items": enriched,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No tasks found.")
return
}
for i, item := range enriched {
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(w, " Created: %s\n", created)
}
if dueAt, _ := item["due_at"].(string); dueAt != "" {
fmt.Fprintf(w, " Due: %s\n", dueAt)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["creator_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
filter["assignee_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
filter["follower_ids"] = ids
}
if runtime.Cmd.Flags().Changed("completed") {
filter["is_completed"] = runtime.Bool("completed")
}
if dueRange := runtime.Str("due"); dueRange != "" {
start, end, err := parseTimeRangeRFC3339(dueRange)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
}
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
mergeIntoFilter(filter, dueFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
}
}
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
if err != nil {
return nil, err
}
task, _ := data["task"].(map[string]interface{})
if task == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
}
return task, nil
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTaskSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "query creator due and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "release")
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
_ = cmd.Flags().Set("completed", "true")
_ = cmd.Flags().Set("due", "-1d,+1d")
_ = cmd.Flags().Set("page-token", "pt_123")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" || body["page_token"] != "pt_123" {
t.Fatalf("unexpected body: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := dueTime["start_time"].(string)
endTime, _ := dueTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected due_time: %#v", dueTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
{
name: "assignee follower and incomplete",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("assignee", "ou_assignee")
_ = cmd.Flags().Set("follower", "ou_follower")
_ = cmd.Flags().Set("completed", "false")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
t.Fatalf("unexpected filter: %#v", filter)
}
if filter["is_completed"] != false {
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTaskSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTaskSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
},
{
name: "dry run error on invalid due",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("due", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTask.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSearchTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
},
{
name: "fallback to app link",
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-999",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
},
{
name: "empty pretty with pagination",
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasks found."},
},
{
name: "pretty with next page token",
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_next",
"items": []interface{}{
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-321",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
},
},
})
},
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
var SetAncestorTask = common.Shortcut{
Service: "task",
Command: "+set-ancestor",
Description: "set or clear a task ancestor",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task guid to update", Required: true},
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
taskID := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
QueryParams: queryParams,
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
}
}
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
return err
}
outData := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{
"guid": taskID,
},
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
} else {
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
}
})
return nil
},
}
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
if ancestorID == "" {
return map[string]interface{}{}
}
return map[string]interface{}{
"ancestor_guid": ancestorID,
}
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildSetAncestorBody(t *testing.T) {
tests := []struct {
name string
ancestorID string
want map[string]interface{}
}{
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildSetAncestorBody(tt.ancestorID)
if len(got) != len(tt.want) {
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
}
for k, want := range tt.want {
if got[k] != want {
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
}
}
})
}
}
func TestSetAncestorTask_DryRun(t *testing.T) {
tests := []struct {
name string
taskID string
ancestor string
wantParts []string
}{
{
name: "with ancestor",
taskID: "task-123",
ancestor: "task-456",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
},
{
name: "clear ancestor",
taskID: "task-123",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("ancestor-id", "", "")
_ = cmd.Flags().Set("task-id", tt.taskID)
if tt.ancestor != "" {
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
out := SetAncestorTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSetAncestorTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "json output with ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"guid": "task-123"`},
},
{
name: "pretty output clears ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
},
{
name: "api-level error (code!=0) returns error",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 10003,
"msg": "permission denied",
},
})
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if out := stdout.String(); out != "" {
t.Fatalf("expected empty stdout on error, got: %s", out)
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
var SubscribeTaskEvent = common.Shortcut{
Service: "task",
Command: "+subscribe-event",
Description: "subscribe to task events",
Risk: "write",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/task/v2/task_v2/task_subscription").
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
QueryParams: queryParams,
})
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
// Parse and validate the envelope to avoid false-success output.
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
}
}
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
return err
}
outData := map[string]interface{}{"ok": true}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
})
return nil
},
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSubscribeTaskEvent(t *testing.T) {
tests := []struct {
name string
mode string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "execute json (user identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute json (bot identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute api error",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 401,
"msg": "Unauthorized",
"error": map[string]interface{}{
"log_id": "test-log-id",
},
},
})
},
wantErr: true,
wantParts: []string{"Unauthorized"},
},
{
name: "dry run",
mode: "dryrun",
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.mode {
case "execute":
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
if tt.register != nil {
tt.register(reg)
}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
out := err.Error()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("error missing %q: %s", want, out)
}
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
case "dryrun":
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
}
})
}
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
tasklistSearchDefaultPageLimit = 20
tasklistSearchMaxPageLimit = 40
)
var SearchTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-search",
Description: "search tasklists",
Risk: "read",
Scopes: []string{"task:tasklist:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTasklistSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = tasklistSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = tasklistSearchMaxPageLimit
}
if pageLimit > tasklistSearchMaxPageLimit {
pageLimit = tasklistSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasklists")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
tasklistID, _ := itemMap["id"].(string)
if tasklistID == "" {
continue
}
tasklist, err := getTasklistDetail(runtime, tasklistID)
if err != nil {
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklistID,
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
})
continue
}
urlVal, _ := tasklist["url"].(string)
urlVal = truncateTaskURL(urlVal)
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklist["guid"],
"name": tasklist["name"],
"url": urlVal,
"creator": tasklist["creator"],
})
}
outData := map[string]interface{}{
"items": tasklists,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
if len(tasklists) == 0 {
fmt.Fprintln(w, "No tasklists found.")
return
}
for i, tasklist := range tasklists {
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["user_id"] = ids
}
if createTime := runtime.Str("create-time"); createTime != "" {
start, end, err := parseTimeRangeRFC3339(createTime)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
}
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
mergeIntoFilter(filter, timeFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
}
}
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
if err != nil {
return nil, err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
if tasklist == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
}
return tasklist, nil
}

View File

@@ -0,0 +1,263 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTasklistSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "creator create-time and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("creator", "ou_creator")
_ = cmd.Flags().Set("create-time", "-7d,+0d")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := createTime["start_time"].(string)
endTime, _ := createTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected create_time: %#v", createTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTasklistSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTasklistSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("create-time", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTasklist.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTasklist.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSearchTasklist_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
},
},
})
},
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
},
{
name: "fallback on detail error",
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "tl-fallback"`},
},
{
name: "pretty fallback avoids nil name",
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
},
{
name: "empty pretty with pagination",
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasklists found."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -60,6 +60,48 @@ lark-cli mail user_mailbox.messages -h
`-h` 输出即可用 flag 的权威来源。reference 文档中的参数表可辅助理解语义,但实际 flag 名称以 `-h` 为准。
### 收件人搜索:查找邮箱地址
当需要查找收件人邮箱地址时,使用联系人搜索接口。支持多种搜索方式,如:
- **按人名搜索**:如"给张三发邮件" → query="张三"
- **按邮箱关键词搜索**:如"发到 larkmail 的邮箱" → query="@larkmail"
- **按群名搜索**:如"发给项目群" → query="项目群"
```bash
lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
```
搜索结果包含多种实体类型:
| `type` 值 | `tag` 示例 | 说明 |
|-----------|-----------|------|
| `user` / `chatter` | `chatter` | 个人用户 |
| `enterprise_mail_group` | `mail_group` | 企业邮件组 |
| `chat` / `group` | `chat_group_tenant` / `chat_group_normal` | 群聊(有群邮件地址) |
| `external_contact` | `external_contact` | 外部联系人 |
**处理规则:**
1. 从结果中筛选有 `email` 字段的条目
2. 无论匹配数量多少,都必须列出候选项供用户确认后再使用(搜索是模糊匹配,单条结果不代表精确命中)。展示尽可能多的字段帮助用户区分:
```text
找到以下匹配"张三"的结果:
1. 张三 <zhangsan@example.com>
类型user | 部门:研发团队
---
找到多个匹配"组"的结果,请选择:
1. 团队邮件组 <team@example.com>
类型enterprise_mail_group | 标签mail_group
2. 项目群 <project@example.com>
类型chat | 成员数50 | 标签chat_group_normal
3. 张群 <zhangqun@example.com>
类型user | 部门:研发团队 | 备注名:张群同学
```
可用字段:`name`(名称)、`email`(邮箱)、`department`(部门)、`tag`(标签)、`display_name`(备注名)、`type`(实体类型)、`member_count`(成员数,群类型时展示)。字段为空时省略。
3. 若无匹配,告知用户未找到,建议换关键词或直接提供邮箱地址
4. 用户确认后,将 `email` 传入 compose shortcut 的 `--to` / `--cc` / `--bcc` 参数
**注意:** 用户直接提供完整邮箱地址时不需要搜索,直接使用即可。
### 命令选择:先判断邮件类型,再决定草稿还是发送
| 邮件类型 | 存草稿(不发送) | 直接发送 |
@@ -117,6 +159,30 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
返回每个收件人的投递状态(`status`1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
### 撤回邮件
发送成功后,若响应中包含 `recall_available: true`说明该邮件支持撤回24 小时内已投递的邮件)。
**撤回操作:**
```bash
lark-cli mail user_mailbox.sent_messages recall --as user \
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
```
- 返回 `recall_status: available` 表示撤回请求已受理(异步执行)
- 返回 `recall_status: unavailable` 表示不可撤回,`recall_restriction_reason` 说明原因
**查询撤回进度:**
```bash
lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
```
- `recall_status: in_progress` — 撤回进行中,可稍后再查
- `recall_status: done` — 撤回完成,查看 `recall_result``all_success` / `all_fail` / `some_fail`)和每个收件人的详情
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

View File

@@ -126,4 +126,40 @@ lark-cli sheets spreadsheet.sheet.filters update \
**常见错误:**
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
- `Excess Limit`update 时重复添加同一列条件
- `Excess Limit`update 时重复添加同一列条件
### 单元格数据类型
接受二维数组的 shortcut`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
| 类型 | 写入格式 | 示例 |
|------|---------|------|
| 字符串 | `"文本"` | `"hello"` |
| 数字 | `数字` | `123`、`3.14` |
| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` |
| 链接(纯 URL | `"URL 字符串"` | `"https://example.com"` |
| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` |
| 邮箱 | `"邮箱字符串"` | `"user@example.com"` |
| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` |
| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`notify 可选,默认 false仅在用户明确要求通知时设为 true |
| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` |
| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` |
**写入公式示例**
```bash
# ✅ 正确:使用对象格式
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]'
# ❌ 错误:直接传字符串,会被存为纯文本
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[["=SUM(C2:C5)"]]'
```
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
**限制**
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
- @人仅支持同租户用户,单次最多 50 人
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号

View File

@@ -14,6 +14,9 @@ metadata:
**CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。**
**CRITICAL — 凡涉及【预约日程/会议】或【查询/搜索会议室】,第一步 MUST 强制使用 Read 工具读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut**
**CRITICAL — 术语约束:用户日常表达中常说的“帮我约个日历”、“查一下今天的日历”等,其实际意图通常是针对 日程Event 的创建或查询,而非操作 日历Calendar 容器本身。请自动将口语化的“日历”意图映射为“日程”操作(如 `+create`, `+agenda`)。**
**CRITICAL — 会议与日程的意图路由:**
- **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。
- **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排则属于本技能lark-calendar的业务域请继续使用本技能处理。
**时间与日期推断规范:**

View File

@@ -17,7 +17,7 @@ Subscribe to Lark events via WebSocket long connection, outputting NDJSON to std
## Commands
```bash
# Subscribe to all registered events (catch-all mode, 24 common event types)
# Subscribe to all registered events (catch-all mode, 25 common event types)
lark-cli event +subscribe
# Subscribe to specific event types only
@@ -153,6 +153,7 @@ The following 24 event types are registered in catch-all mode (when `--event-typ
| Event Type | Description | Required Scope |
|-----------|-------------|---------------|
| `task.task.update_tenant_v1` | Task updated (tenant) | `task:task:readonly` |
| `task.task.update_user_access_v2` | Task updated (user access) | `task:task:readonly` |
| `task.task.comment_updated_v1` | Task comment updated | `task:task:readonly` |
### Drive

View File

@@ -74,6 +74,48 @@ lark-cli mail user_mailbox.messages -h
`-h` 输出即可用 flag 的权威来源。reference 文档中的参数表可辅助理解语义,但实际 flag 名称以 `-h` 为准。
### 收件人搜索:查找邮箱地址
当需要查找收件人邮箱地址时,使用联系人搜索接口。支持多种搜索方式,如:
- **按人名搜索**:如"给张三发邮件" → query="张三"
- **按邮箱关键词搜索**:如"发到 larkmail 的邮箱" → query="@larkmail"
- **按群名搜索**:如"发给项目群" → query="项目群"
```bash
lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}'
```
搜索结果包含多种实体类型:
| `type` 值 | `tag` 示例 | 说明 |
|-----------|-----------|------|
| `user` / `chatter` | `chatter` | 个人用户 |
| `enterprise_mail_group` | `mail_group` | 企业邮件组 |
| `chat` / `group` | `chat_group_tenant` / `chat_group_normal` | 群聊(有群邮件地址) |
| `external_contact` | `external_contact` | 外部联系人 |
**处理规则:**
1. 从结果中筛选有 `email` 字段的条目
2. 无论匹配数量多少,都必须列出候选项供用户确认后再使用(搜索是模糊匹配,单条结果不代表精确命中)。展示尽可能多的字段帮助用户区分:
```text
找到以下匹配"张三"的结果:
1. 张三 <zhangsan@example.com>
类型user | 部门:研发团队
---
找到多个匹配"组"的结果,请选择:
1. 团队邮件组 <team@example.com>
类型enterprise_mail_group | 标签mail_group
2. 项目群 <project@example.com>
类型chat | 成员数50 | 标签chat_group_normal
3. 张群 <zhangqun@example.com>
类型user | 部门:研发团队 | 备注名:张群同学
```
可用字段:`name`(名称)、`email`(邮箱)、`department`(部门)、`tag`(标签)、`display_name`(备注名)、`type`(实体类型)、`member_count`(成员数,群类型时展示)。字段为空时省略。
3. 若无匹配,告知用户未找到,建议换关键词或直接提供邮箱地址
4. 用户确认后,将 `email` 传入 compose shortcut 的 `--to` / `--cc` / `--bcc` 参数
**注意:** 用户直接提供完整邮箱地址时不需要搜索,直接使用即可。
### 命令选择:先判断邮件类型,再决定草稿还是发送
| 邮件类型 | 存草稿(不发送) | 直接发送 |
@@ -131,6 +173,30 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
返回每个收件人的投递状态(`status`1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。
### 撤回邮件
发送成功后,若响应中包含 `recall_available: true`说明该邮件支持撤回24 小时内已投递的邮件)。
**撤回操作:**
```bash
lark-cli mail user_mailbox.sent_messages recall --as user \
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
```
- 返回 `recall_status: available` 表示撤回请求已受理(异步执行)
- 返回 `recall_status: unavailable` 表示不可撤回,`recall_restriction_reason` 说明原因
**查询撤回进度:**
```bash
lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
```
- `recall_status: in_progress` — 撤回进行中,可稍后再查
- `recall_status: done` — 撤回完成,查看 `recall_result``all_success` / `all_fail` / `some_fail`)和每个收件人的详情
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
### 正文格式:优先使用 HTML
撰写邮件正文时,**默认使用 HTML 格式**body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。
@@ -250,6 +316,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`
| [`+draft-create`](references/lark-mail-draft-create.md) | Create a brand-new mail draft from scratch (NOT for reply or forward). For reply drafts use +reply; for forward drafts use +forward. Only use +draft-create when composing a new email with no parent message. |
| [`+draft-edit`](references/lark-mail-draft-edit.md) | Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible. |
| [`+forward`](references/lark-mail-forward.md) | Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically. |
| [`+signature`](references/lark-mail-signature.md) | List or view email signatures with default usage info. |
## API Resources
@@ -329,6 +396,7 @@ lark-cli mail <resource> <method> [flags] # 调用 API
### user_mailbox.settings
- `get_signatures` — 获取用户邮箱签名列表
- `send_as` — 获取账号的所有可发信地址,包括主地址、别名地址、邮件组。可以使用用户地址访问该接口,也可以使用用户有权限的公共邮箱地址访问该接口。
### user_mailbox.threads
@@ -340,6 +408,11 @@ lark-cli mail <resource> <method> [flags] # 调用 API
- `modify` — 本接口提供修改邮件会话的能力支持移动邮件会话的文件夹、给邮件会话添加和移除标签、标记邮件会话读和未读、移动邮件会话至垃圾邮件等能力。不支持移动邮件会话到已删除文件夹如需请使用删除邮件会话接口。至少填写add_label_ids、remove_label_ids、add_folder中的一个参数。
- `trash` — 移动指定的邮件会话到已删除文件夹
### user_mailbox.sent_messages
- `recall` — 撤回指定邮件。前置条件:邮件须已投递,且发送时间在 24 小时以内;搬家中的域名不支持撤回。返回说明:若用户或邮件不满足撤回条件,接口仍返回 200响应体中 recall_status 为 unavailablerecall_restriction_reason 标明具体原因。返回成功仅表示撤回请求已受理,实际撤回结果请调用「查询邮件撤回进度」接口获取。
- `get_recall_detail` — 查询指定邮件的撤回结果详情,包括整体撤回进度、成功/失败/处理中的收件人数量,以及每个收件人的撤回状态和失败原因。
## 权限表
| 方法 | 所需 scope |
@@ -384,6 +457,7 @@ lark-cli mail <resource> <method> [flags] # 调用 API
| `user_mailbox.rules.list` | `mail:user_mailbox.rule:read` |
| `user_mailbox.rules.reorder` | `mail:user_mailbox.rule:write` |
| `user_mailbox.rules.update` | `mail:user_mailbox.rule:write` |
| `user_mailbox.settings.get_signatures` | `mail:user_mailbox:readonly` |
| `user_mailbox.settings.send_as` | `mail:user_mailbox:readonly` |
| `user_mailbox.threads.batch_modify` | `mail:user_mailbox.message:modify` |
| `user_mailbox.threads.batch_trash` | `mail:user_mailbox.message:modify` |
@@ -391,4 +465,6 @@ lark-cli mail <resource> <method> [flags] # 调用 API
| `user_mailbox.threads.list` | `mail:user_mailbox.message:readonly` |
| `user_mailbox.threads.modify` | `mail:user_mailbox.message:modify` |
| `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` |
| `user_mailbox.sent_messages.recall` | `mail:user_mailbox.message:modify` |
| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |

View File

@@ -51,6 +51,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -219,6 +219,22 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
{ "op": "remove_inline", "target": { "cid": "logo" } }
```
`insert_signature`
```json
{ "op": "insert_signature", "signature_id": "<签名ID>" }
```
插入签名到正文末尾(引用块之前)。如已有签名则先移除再插入。运行 `mail +signature` 获取可用签名 ID。签名中的模板变量会自动替换内联图片自动下载嵌入。
`remove_signature`
```json
{ "op": "remove_signature" }
```
移除草稿中的现有签名(含签名前的空行间距)。如签名包含内联图片且正文不再引用这些图片,对应的 MIME part 也会一并移除。
注意事项:
- `ops` 按顺序执行
@@ -228,6 +244,7 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
- **`set_body` 是完整替换** — 它替换整个正文内容(包括引用区)
- **`set_reply_body` 仅替换引用区前面的用户撰写部分** — 引用区自动重新拼接value 只传用户撰写内容,不要包含引用区;如果用户要修改引用区内容,用 `set_body` 全量覆盖
- 通过 `--inspect` 返回的 `has_quoted_content` 字段可判断草稿是否包含引用区
- 通过 `--inspect` 返回的 `has_signature` / `signature_id` 字段可判断草稿是否包含签名
## 返回值

View File

@@ -66,6 +66,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -70,6 +70,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -73,6 +73,7 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -70,6 +70,7 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
| `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -0,0 +1,98 @@
# mail +signature
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查看邮箱签名列表或详情。返回签名的类型、默认使用情况、内容预览等信息。TENANT企业签名的模板变量会被自动替换为实际值。
本 skill 对应 shortcut`lark-cli mail +signature`
## 命令
```bash
# 列出所有签名
lark-cli mail +signature
# 查看某个签名的详情(渲染后的内容预览、模板变量值、图片信息)
lark-cli mail +signature --detail <signature_id>
# 指定邮箱
lark-cli mail +signature --from shared@example.com
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--from <email>` | 否 | 邮箱地址(默认 `me` |
| `--detail <id>` | 否 | 签名 ID查看详情。省略则列出所有签名 |
## 返回值
**列表模式:**
```json
{
"ok": true,
"data": {
"signatures": [
{
"id": "<签名ID>",
"name": "个人签名",
"type": "USER",
"content_preview": "这是我的签名内容 [image] 超链接哈哈"
},
{
"id": "<签名ID>",
"name": "企业签名",
"type": "TENANT",
"is_send_default": true,
"is_reply_default": true,
"content_preview": "企业签名 姓名:陈煌 部门:研发团队"
}
]
}
}
```
**详情模式(`--detail`**
```json
{
"ok": true,
"data": {
"id": "<签名ID>",
"name": "企业签名",
"type": "TENANT",
"is_send_default": true,
"is_reply_default": true,
"images": [
{"cid": "76CEB29E-...", "file_key": "121011...", "image_name": "image.png"}
],
"template_vars": {"B-NAME": "陈煌", "B-DEPARTMENT": "研发团队"},
"content_preview": "企业签名 姓名:陈煌 部门:研发团队"
}
}
```
## 字段说明
| 字段 | 说明 |
|------|------|
| `type` | `USER`(用户签名,可编辑)或 `TENANT`(企业签名,管理员模板控制) |
| `is_send_default` | 是否为新邮件的默认签名 |
| `is_reply_default` | 是否为回复/转发的默认签名 |
| `images` | 签名内联图片元数据(仅详情模式) |
| `template_vars` | TENANT 签名的模板变量已替换值(仅详情模式) |
| `content_preview` | 签名内容的纯文本预览(`<img>` 显示为 `[image]`,最长 200 字符) |
## 与 compose shortcut 配合
获取签名 ID 后,可在发送/回复/转发时附加签名:
```bash
# 查看签名列表获取 ID
lark-cli mail +signature
# 在发送邮件时附加签名
lark-cli mail +send --to alice@example.com --subject '你好' --body '<p>内容</p>' --signature-id <签名ID>
```

View File

@@ -141,6 +141,42 @@ lark-cli sheets spreadsheet.sheet.filters update \
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
- `Excess Limit`update 时重复添加同一列条件
### 单元格数据类型
接受二维数组的 shortcut`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
| 类型 | 写入格式 | 示例 |
|------|---------|------|
| 字符串 | `"文本"` | `"hello"` |
| 数字 | `数字` | `123`、`3.14` |
| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` |
| 链接(纯 URL | `"URL 字符串"` | `"https://example.com"` |
| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` |
| 邮箱 | `"邮箱字符串"` | `"user@example.com"` |
| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` |
| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`notify 可选,默认 false仅在用户明确要求通知时设为 true |
| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` |
| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` |
**写入公式示例**
```bash
# ✅ 正确:使用对象格式
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]'
# ❌ 错误:直接传字符串,会被存为纯文本
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[["=SUM(C2:C5)"]]'
```
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
**限制**
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
- @人仅支持同租户用户,单次最多 50 人
- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-set-dropdown.md`](references/lark-sheets-set-dropdown.md)。值中的字符串不能包含逗号
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
@@ -176,6 +212,15 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
| [`+get-filter-view-condition`](references/lark-sheets-get-filter-view-condition.md) | Get a filter condition by column |
| [`+delete-filter-view-condition`](references/lark-sheets-delete-filter-view-condition.md) | Delete a filter condition |
### 下拉列表
| Shortcut | 说明 |
|----------|------|
| [`+set-dropdown`](references/lark-sheets-set-dropdown.md) | 设置下拉列表(`multipleValue` 写入的前置步骤) |
| [`+update-dropdown`](references/lark-sheets-update-dropdown.md) | 更新下拉列表选项 |
| [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 |
| [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 |
## API Resources
```bash

View File

@@ -0,0 +1,46 @@
# sheets +delete-dropdown删除下拉列表
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +delete-dropdown`
删除指定范围的下拉列表配置。支持一次删除多个范围。
> [!CAUTION]
> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 删除单个范围
lark-cli sheets +delete-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--ranges '["<sheetId>!A2:A100"]'
# 删除多个范围
lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \
--ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A2:A100"]'`),单个范围最多 5000 格,单次最多 100 个范围 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含
- `rangeResults[].range` — 对应的范围
- `rangeResults[].success` — 是否成功
- `rangeResults[].updatedCells` — 影响的单元格数量
## 参考
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表

View File

@@ -0,0 +1,89 @@
# 飞书表格公式规则
> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill普通公式对区域默认"投影"(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。
## 写入方式
公式必须使用对象格式写入(参见 SKILL.md「单元格数据类型」
```bash
--values '[[{"type":"formula","text":"=SUM(A1:A10)"}]]'
```
## ARRAYFORMULA 判断流程
1. 结果是**标量**(单值)→ 不需要
2. 结果是**数组**,且公式中**有**原生数组函数 → 不需要(数组语义自动传播)
3. 结果是**数组**,且公式中**无**原生数组函数,对区域做标量计算 → 加 `ARRAYFORMULA`
```text
# 有原生数组函数,无需包裹
=FILTER(A2:A10,B2:B10="x")+1 ✓
=XLOOKUP(E2:E10,A2:A10,B2:B10)*100 ✓
=MAP(A2:A10,LAMBDA(x,x*2))-1 ✓
# 无原生数组函数,必须包裹
=ARRAYFORMULA(A2:A100*B2:B100) ✓
=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))✓
```
## 原生数组函数清单(无需 ARRAYFORMULA
`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP`
## 高风险函数INDEX / OFFSET / ROW / COLUMN / MATCH
行号/列号/偏移量本身是数组时,必须显式包裹:
```text
=ARRAYFORMULA(INDEX(...))
=ARRAYFORMULA(ROW(...))
```
例外:结果直接交给聚合函数消费时不需要:`=SUM(INDEX(A1:B2,0,1))`
## 隐式逐项求值 → MAP/LAMBDA
Excel 中 `SUBTOTAL``INDIRECT``OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 MAP 显式遍历:
```text
# Excel
=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))
# 飞书
=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r)))))
```
同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 MAP。
## 多维结果降维
飞书公式结果只能是二维,不能返回"区域的列表"。合并多个区域时:
| 需求 | 写法 |
|------|------|
| 上下堆叠 | `=VSTACK(a, b, c)` |
| 左右拼接 | `=HSTACK(a, b, c)` |
| 压成单列 | `=TOCOL(...)` |
| 压成单行 | `=TOROW(...)` |
| 归约为标量 | `=REDUCE(init, arr, LAMBDA(acc, x, ...))` |
## 日期差
| 需求 | 正确写法 | 错误写法 |
|------|---------|---------|
| 天数差 | `=DAYS(B2,A2)``=DATEDIF(A2,B2,"D")``=B2-A2` | `=DAY(B2-A2)` |
| 月份差 | `=DATEDIF(A2,B2,"M")` | `=MONTH(B2-A2)` |
| 年份差 | `=DATEDIF(A2,B2,"Y")` | `=YEAR(B2-A2)` |
| 工作日差 | `=NETWORKDAYS(A2,B2)` | — |
## 飞书不支持的 Excel 语法
| Excel 语法 | 飞书替代 |
|-----------|---------|
| `=@A1:A10`(隐式交叉) | `=A1:A10`(飞书默认投影,去掉 `@` |
| `=A1#`spill range | 改成明确范围,或用 `TAKE`/`DROP`/`ARRAY_CONSTRAIN` |
| `=SUM(Table1[Amount])`(结构化引用) | `=SUM(A2:A100)`(改为 A1 区域) |
| `{=A1:A10*B1:B10}`CSE 花括号) | `=ARRAYFORMULA(A1:A10*B1:B10)` |
| `STOCKHISTORY` / `WEBSERVICE` / `CUBE*` | 飞书无等价函数 |

View File

@@ -0,0 +1,43 @@
# sheets +get-dropdown查询下拉列表
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +get-dropdown`
查询指定范围内已配置的下拉列表设置,包括选项值、是否多选、颜色映射等。
## 命令
```bash
lark-cli sheets +get-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--range "<sheetId>!A2:A100"
lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A2:A100"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100` |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含
- `dataValidations[].conditionValues` — 下拉选项列表
- `dataValidations[].ranges` — 应用范围
- `dataValidations[].options.multipleValues` — 是否多选
- `dataValidations[].options.highlightValidData` — 是否着色
- `dataValidations[].options.colorValueMap` — 选项颜色映射
## 参考
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表

View File

@@ -0,0 +1,62 @@
# sheets +set-dropdown设置下拉列表
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +set-dropdown`
为指定范围的单元格配置下拉列表选项。**这是使用 `multipleValue` 格式写入数据的前置步骤**——未配置下拉选项的单元格,`multipleValue` 写入会变成纯文本。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
# 基础:设置单选下拉
lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]'
# 多选 + 颜色高亮
lark-cli sheets +set-dropdown --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' \
--multiple --highlight --colors '["#1FB6C1", "#F006C2", "#FB16C3"]'
# 仅预览参数(不发请求)
lark-cli sheets +set-dropdown --url "https://..." --range "..." --condition-values '...' --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`),单次最多 5000 行 x 100 列 |
| `--condition-values` | 是 | 下拉选项JSON 数组(如 `'["选项1","选项2"]'`),最多 500 个,每个 ≤100 字符,不能包含逗号 |
| `--multiple` | 否 | 是否多选,默认 false |
| `--highlight` | 否 | 是否着色,默认 false |
| `--colors` | 否 | RGB 十六进制颜色 JSON 数组,需与 `--condition-values` 一一对应(`--highlight` 时必填) |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `code`0=成功)和 `msg`
## 典型流程
```bash
# 1. 先配置下拉选项
lark-cli sheets +set-dropdown --url "<url>" \
--range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple
# 2. 再用 multipleValue 写入
lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \
--values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]'
```
## 参考
- [lark-sheets-update-dropdown](lark-sheets-update-dropdown.md) — 更新下拉列表
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表

View File

@@ -0,0 +1,51 @@
# sheets +update-dropdown更新下拉列表
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +update-dropdown`
更新已有下拉列表的选项、颜色等配置。可同时更新多个范围。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
lark-cli sheets +update-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" \
--ranges '["<sheetId>!A1:A100", "<sheetId>!C1:C100"]' \
--condition-values '["新选项1", "新选项2", "新选项3"]'
# 更新为多选 + 着色
lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" \
--ranges '["<sheetId>!A1:A100"]' \
--condition-values '["选项A", "选项B"]' \
--multiple --highlight --colors '["#1FB6C1", "#F006C2"]'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--ranges` | 是 | 范围 JSON 数组(如 `'["sheetId!A1:A100"]'` |
| `--condition-values` | 是 | 新的下拉选项JSON 数组 |
| `--multiple` | 否 | 是否多选,默认 false |
| `--highlight` | 否 | 是否着色,默认 false |
| `--colors` | 否 | RGB 颜色 JSON 数组,需与 `--condition-values` 一一对应 |
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
## 输出
JSON包含 `spreadsheetToken``sheetId``dataValidation`(选项值和颜色映射)。
## 参考
- [lark-sheets-set-dropdown](lark-sheets-set-dropdown.md) — 设置下拉列表
- [lark-sheets-get-dropdown](lark-sheets-get-dropdown.md) — 查询下拉列表
- [lark-sheets-delete-dropdown](lark-sheets-delete-dropdown.md) — 删除下拉列表

View File

@@ -83,6 +83,14 @@ Step 2: 生成大纲 → 用户确认 → 创建
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
- 10 页以内:用 slides +create --slides '[...]' 一步创建 PPT 并添加所有页面
- 超过 10 页:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
- 含本地图片:
· 新建带图 PPT —— 在 slide XML 里写 <img src="@./pic.png" .../>
+create 会自动上传并替换为 file_token详见 lark-slides-create.md
· 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID`
拿到 file_token再用它写进 slide XML 调 xml_presentation.slide.create
· 给已有页加图 —— XML API 无元素级编辑,需要整页替换;必守规则和流程见下方「给已有 PPT 的已有页加图」章节
· 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png
绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行
- 每页 slide 需要完整的 XML背景、文本、图形、配色
- 复杂元素table、chart需参考 XSD 原文
@@ -99,6 +107,7 @@ Step 3: 审查 & 交付
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
```bash
# 追加到末尾
lark-cli slides xml_presentation.slide create \
--as user \
--params '{"xml_presentation_id":"YOUR_ID"}' \
@@ -108,8 +117,29 @@ lark-cli slides xml_presentation.slide create \
在这里放置 shape、line、table、chart 等元素
</data>
</slide>' '{slide:{content:$content}}')"
# 插到指定页之前before_slide_id 必须在 --data body 里,与 slide 同级
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
lark-cli slides xml_presentation.slide create \
--as user \
--params '{"xml_presentation_id":"YOUR_ID"}' \
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
'{slide:{content:$content}, before_slide_id:$before}')"
```
### 给已有 PPT 的已有页加图(整页替换)
XML API 没有元素级编辑接口(见核心规则 7。想给某一页加图只能**整页替换**:读原 slide → 加 `<img>` → 原位 create 新页 → 删除旧页。
**必守 4 条**
1. **先 create 后 delete** —— 顺序反了且 create 失败会丢页
2. **原 slide 的所有元素必须原样搬到新 XML**(标题、正文、形状、原有 img—— 只写新 `<img>` 会把原页其他内容全删掉
3. **`before_slide_id=<旧 slide_id>` 必传,且必须放在 `--data` body 里**(与 `slide` 同级),**不能放在 `--params`** —— `--params` 只接 path/query 参数body 字段塞进去会被 CLI 当未知 query 参数静默下发,服务端忽略,结果是新页追加到末尾、打乱页序。正确形状:`--data '{"slide":{"content":"..."},"before_slide_id":"<旧 slide_id>"}'`
4. **新 `<img>` 坐标避开现有元素** —— 读原 `<data>` 里元素的 `topLeftX/Y/width/height` 挑空白区;空间不够就先缩小/挪动现有元素再放图
完整 bash 模板和 `+media-upload` 参数见 [+media-upload 文档](references/lark-slides-media-upload.md)。
### 风格快速判断表
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
@@ -229,7 +259,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面bot 模式自动授权 |
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传bot 模式自动授权 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
## API Resources
@@ -257,12 +288,15 @@ lark-cli slides <resource> <method> [flags] # 调用 API
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **没有元素级编辑能力**:飞书 slides XML API 只有 slide 级 `create` / `delete`**没有更新单个 shape/img 坐标或尺寸的接口**。不要向用户承诺"微调坐标/尺寸"、"挪一下这个图"。要改只能整页重建(`xml_presentations.get` 读回 → 改 XML → `slide.delete` 旧页 + `slide.create` 新页),且 `slide_id` 会变、默认追加到末尾(要回原位需 `before_slide_id`)。整页重建前必须先告知用户代价并确认
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only` |
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
| `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` |
| `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` |
@@ -278,6 +312,9 @@ lark-cli slides <resource> <method> [flags] # 调用 API
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
| 403 | 权限不足 | 检查是否拥有对应的 scope |
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
| 1061002 | params error媒体上传时 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`slides 唯一可用 `parent_type` 是 `slide_file` |
| 1061004 | forbidden当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
## 创建前自查
@@ -301,17 +338,22 @@ lark-cli slides <resource> <method> [flags] # 调用 API
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 |
| 图片被裁掉一部分 | `<img>` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 |
| 给已有页加图后,原页文字/形状消失了 | 替换整页时必须把原 slide 的 `<data>` 所有元素原样搬过来,不能只写新 `<img>` |
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 |
| 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) |
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
| API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 |
| API 返回 3350001 | `xml_presentation_id` 不是通过 `xml_presentations.create` 创建的,或 token 不正确 |
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src |
| 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`slides 唯一接受值);不要手拼,用 `slides +media-upload` |
## 参考文档
| 文档 | 说明 |
|------|------|
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut创建 PPT支持 `--slides` 一步添加页面)** |
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut创建 PPT支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片** |
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut上传本地图片返回 `file_token`** |
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |

View File

@@ -34,6 +34,7 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
- **`revision_id`**integer演示文稿版本号
- **`slide_ids`**string[],可选):仅传 `--slides` 时返回,成功添加的页面 ID 列表
- **`slides_added`**integer可选仅传 `--slides` 时返回,成功添加的页面数量
- **`images_uploaded`**integer可选`--slides` 中含 `@<本地路径>` 占位符时返回,已上传的去重后图片数量
- **`permission_grant`**object可选`--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
> [!IMPORTANT]
@@ -68,6 +69,43 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
JSON string 数组,每个元素是一页 slide 的完整 XML。CLI 内部负责包装成 API 所需的 `{"slide": {"content": "..."}}` 格式并逐页调用。
### 本地图片:`@<path>` 占位符
`<img>` 元素的 `src` 属性如果以 `@` 开头CLI 会把它当作本地文件路径,自动上传到当前演示文稿,并把占位符替换为返回的 `file_token`
```bash
lark-cli slides +create --as user --title "图测试" --slides '[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@./assets/chart.png\" topLeftX=\"100\" topLeftY=\"100\" width=\"320\" height=\"180\"/></data></slide>"
]'
```
行为:
- 路径相对于**当前工作目录**CWD解析**必须是 CWD 内的相对路径**(如 `./pic.png``./assets/x.png`
- 同一份图被多次引用时**只上传一次**(按路径去重)
- `src` 不以 `@` 开头的会原样保留,但**只允许写 `slides +media-upload` 拿到的 `file_token`****禁止写 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 通常显示破图。要用网图必须先下载到 CWD 内、再走上传流程
- 单张图片最大 20 MBslides upload API 不支持分片上传)
- 校验阶段就会检查所有占位符文件存在及大小;缺文件或超限直接报错,不会创建空白 PPT 占位
- 创空白 PPT → 上传所有图 → 替换 token → 逐页创建 slide按这个顺序执行
> [!IMPORTANT]
> **路径必须在 CWD 内**`@/abs/path/x.png` 或 `@../up/x.png` 这种会被 CLI 拒绝(报 `unsafe file path`)。如果素材在别的目录,先 `cd` 过去再执行。
### 给已有 PPT 加带图新页
`+create --slides` 只在新建 PPT 时使用 `@` 占位符。给已有 PPT 加带图新页要分两步CLI 没封装这个组合):
```bash
# 1) 上传图片
TOKEN=$(lark-cli slides +media-upload --as user \
--file ./pic.png --presentation $PRES_ID | jq -r .data.file_token)
# 2) 用返回的 file_token 创建带图新页
lark-cli slides xml_presentation.slide create --as user \
--params "{\"xml_presentation_id\":\"$PRES_ID\"}" \
--data "{\"slide\":{\"content\":\"<slide xmlns=\\\"http://www.larkoffice.com/sml/2.0\\\"><data><img src=\\\"$TOKEN\\\" topLeftX=\\\"100\\\" topLeftY=\\\"100\\\" width=\\\"200\\\" height=\\\"200\\\"/></data></slide>\"}}"
```
## 创建后续步骤
如果没有使用 `--slides``slides +create` 返回的 `xml_presentation_id` 用于后续操作:

View File

@@ -0,0 +1,143 @@
# slides +media-upload上传本地图片到飞书幻灯片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
把本地图片上传到指定演示文稿的 drive 媒体库,返回 `file_token`。**返回的 token 作为 `<img src="...">` 的值塞进 slide XML 即可显示图片。**
## 命令
```bash
# 直接传 xml_presentation_id
lark-cli slides +media-upload --as user \
--file ./pic.png \
--presentation slidesXXXXXXXXXXXXXXXXXXXXXX
# 传 slides URL 也行
lark-cli slides +media-upload --as user \
--file ./chart.png \
--presentation "https://xxx.feishu.cn/slides/slidesXXXXXXXXXXXXXXXXXXXXXX"
# 传 wiki URLCLI 自动 wiki.spaces.get_node 解析为真实 token校验 obj_type=slides
lark-cli slides +media-upload --as user \
--file ./pic.png \
--presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX"
# 预览(不实际上传)
lark-cli slides +media-upload --file ./pic.png --presentation $PRES_ID --dry-run
```
## 返回值
```json
{
"file_token": "boxcnXXXXXXXXXXXXXXXXXXXXXX",
"file_name": "pic.png",
"size": 12345,
"presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX"
}
```
- **`file_token`**:把它写进 `<img src="...">`
- **`file_name` / `size`**:上传文件元信息
- **`presentation_id`**:解析后的真实 `xml_presentation_id`wiki URL 解析后会变化)
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径,**必须是 CWD 内的相对路径**(如 `./pic.png`)。**最大 20 MB**slides upload API 不支持分片上传) |
| `--presentation` | 是 | `xml_presentation_id``/slides/<token>` URL`/wiki/<token>` URL |
> [!IMPORTANT]
> **路径必须在 CWD 内**`--file /abs/path/x.png` 或 `--file ../up/x.png` 会被 CLI 拒绝(报 `unsafe file path`)。如果素材在别的目录,先 `cd` 过去再执行。
## 使用流程
### 给已有 PPT 加带图新页
```bash
# 1) 上传图片
TOKEN=$(lark-cli slides +media-upload --as user \
--file ./pic.png \
--presentation $PRES_ID | jq -r .data.file_token)
# 2) 用 file_token 创建带图新页
lark-cli slides xml_presentation.slide create --as user \
--params "{\"xml_presentation_id\":\"$PRES_ID\"}" \
--data "{\"slide\":{\"content\":\"<slide xmlns=\\\"http://www.larkoffice.com/sml/2.0\\\"><data><img src=\\\"$TOKEN\\\" topLeftX=\\\"100\\\" topLeftY=\\\"100\\\" width=\\\"320\\\" height=\\\"180\\\"/></data></slide>\"}}"
```
### 新建带图 PPT推荐用 `+create --slides` 的 `@` 占位符,一步到位)
```bash
# 不需要单独 +media-upload写 src="@<本地路径>" 即可
lark-cli slides +create --as user --title "图测试" --slides '[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@./pic.png\" topLeftX=\"100\" topLeftY=\"100\" width=\"320\" height=\"180\"/></data></slide>"
]'
```
详见 [+create 文档](lark-slides-create.md#本地图片path-占位符)。
### 给已有 PPT 的已有页加图(整页替换)
> ⚠️ slides XML API 没有元素级编辑接口(见 SKILL.md 核心规则 7—— 没法"往现有 slide 上贴一张图",只能**把整页替换**:读原 slide → 加 `<img>` → 原位插入新页 → 删除旧页。
```bash
PRES_ID=xxx
TARGET_SLIDE_ID=yyy # 要加图的那一页
# 1) 上传图片拿 file_token
TOKEN=$(lark-cli slides +media-upload --as user \
--file ./pic.png --presentation $PRES_ID | jq -r '.data.file_token')
# 2) 读整份 PPT摘出目标 slide 的完整 XML保留所有 shape/line/img 原样)
lark-cli slides xml_presentations get --as user \
--params "{\"xml_presentation_id\":\"$PRES_ID\"}" \
| jq -r '.data.xml_presentation.content'
# 3) 在 agent 侧拼新 slide XML原有所有元素原样保留 + 新增一个 <img>
# 关键:先看原 <data> 里现有元素的 topLeftX/Y/width/height把 <img> 放到空白区
# 4) 原位 createbefore_slide_id = 旧 slide_id
lark-cli slides xml_presentation.slide create --as user \
--params "{\"xml_presentation_id\":\"$PRES_ID\"}" \
--data "$(jq -n --arg content "$NEW_XML" --arg before "$TARGET_SLIDE_ID" \
'{slide:{content:$content}, before_slide_id:$before}')"
# 5) create 成功后删旧页
lark-cli slides xml_presentation.slide delete --as user \
--params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$TARGET_SLIDE_ID\"}"
```
**必须遵守**
1. **先 create 后 delete** —— 顺序反了且 create 失败会丢页
2. **原 slide 的所有元素必须原样搬过来** —— 只写新 `<img>` 会把原页标题/正文/形状全删掉
3. **`before_slide_id=<旧 slide_id>` 必传** —— 不传新页追加到末尾,打乱页序
4. **`<img>` 坐标避开现有元素** —— 先读现有元素 bbox 挑空白区;空间不够就缩小/挪动现有元素后再放图
5. **`<img>``width:height` 仍需对齐原图比例** —— 比例不一致会被裁剪,参见 [xml-schema-quick-ref.md](xml-schema-quick-ref.md) `<img>` 说明
6. **`slide_id` 会变** —— 新页有新 ID外部有深链依赖的要告知用户
## 工作原理
`+media-upload` 内部调用 `POST /open-apis/drive/v1/medias/upload_all`(单次上传,最大 20 MB固定使用
- `parent_type=slide_file`slides 后端唯一接受的取值,已实测验证)
- `parent_node=<xml_presentation_id>`
**不要尝试用 `slides_image`、`slide_image` 等 parent_type**——后端会返回 1061001 / 1061002 错误。这是 slides 的特殊约定。
## 常见错误
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 1061002 | params error / 不支持的 parent_type | 不要用原生 API 自己拼 parent_type`+media-upload` 即可 |
| 1061004 | forbidden当前身份对该演示文稿无编辑权限 | 确认当前身份user 或 bot对目标 PPT 有编辑权限。bot 模式常见原因PPT 不是该 bot 创建的——可用 `+create --as bot` 新建,或以 user 身份给 bot 授权 `lark-cli drive permission.members create --as user ...` |
| 1061044 | parent node not exist | `--presentation` 给的 token 不对,或不是 slides 类型 |
| 403 | 权限不足 | 检查 `docs:document.media:upload` scopewiki URL 还需要 `wiki:node:read` |
## 相关命令
- [+create](lark-slides-create.md) — 新建 PPT支持 `@` 占位符自动上传图片)
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 创建 slide 页面(拿到 file_token 后塞进 XML

View File

@@ -161,6 +161,11 @@ lark-cli slides xml_presentation.slide create --as user \
| `<data>` | 图形元素容器shape、img、table、chart 等) |
| `<note>` | 演讲者备注 |
> [!IMPORTANT]
> **本地图片必须先上传**`xml_presentation.slide.create` 不识别 `@./local.png` 占位符(那是 `+create --slides` 的语法糖)。直接调本接口添加带图新页时,必须先用 [`slides +media-upload`](lark-slides-media-upload.md) 拿到 `file_token`,再写进 `<img src="<file_token>">`。
>
> 如果是从零开始建带图 PPT**强烈建议改用 [`slides +create --slides '[...]'`](lark-slides-create.md#本地图片path-占位符)** 一步搞定(自动上传 + 替换 token
## 常见错误
| 错误码 | 含义 | 解决方案 |

View File

@@ -79,6 +79,106 @@ lark-cli slides xml_presentation.slide create --as user \
</slide>
```
## 带图版式
> **关键提醒**`<img>` 的 `width:height` = 原图比例时才不会被裁剪。每个模板都标注了图框比例和建议原图比例,**选模板前先对照你的素材比例**,不要硬塞(如把横图放进竖框,会被左右裁掉大半)。把 `@./your-image.jpg` 替换为实际路径(仅 `+create --slides` 支持 `@` 占位符;其他场景需先用 `slides +media-upload` 拿 `file_token`)。
### 封面右图(左字右图)
图框 400×225**16:9**),建议原图:横幅 16:9桌面壁纸、产品 banner、landscape 照片)
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)"/></fill></style>
<data>
<shape type="text" topLeftX="60" topLeftY="180" width="450" height="80">
<content><p><strong><span color="rgb(255,255,255)" fontSize="44">主标题</span></strong></p></content>
</shape>
<shape type="text" topLeftX="60" topLeftY="270" width="450" height="40">
<content><p><span color="rgb(186,230,253)" fontSize="20">副标题</span></p></content>
</shape>
<line startX="60" startY="350" endX="180" endY="350">
<border color="rgb(59,130,246)" width="3"/>
</line>
<shape type="text" topLeftX="60" topLeftY="370" width="450" height="30">
<content><p><span color="rgb(203,213,225)" fontSize="13">底部信息</span></p></content>
</shape>
<!-- 图框 400×225 = 16:9原图建议 16:9 横幅 -->
<img src="@./your-landscape.jpg" topLeftX="540" topLeftY="157" width="400" height="225"/>
</data>
</slide>
```
### 三卡片带图(上图下文)
每个图框 240×180**4:3**建议原图4:3 或接近正方形的图产品照、截图、icon 类)
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="rgb(248,250,252)"/></fill></style>
<data>
<shape type="text" topLeftX="60" topLeftY="40" width="600" height="45">
<content><p><strong><span color="rgb(15,23,42)" fontSize="28">核心亮点</span></strong></p></content>
</shape>
<line startX="60" startY="95" endX="140" endY="95">
<border color="rgb(59,130,246)" width="3"/>
</line>
<!-- 卡片 1 -->
<shape type="rect" topLeftX="60" topLeftY="130" width="270" height="360">
<fill><fillColor color="rgb(255,255,255)"/></fill>
<border color="rgba(0,0,0,0.08)" width="1"/>
</shape>
<!-- 图框 240×180 = 4:3原图建议 4:3 -->
<img src="@./your-image-1.jpg" topLeftX="75" topLeftY="150" width="240" height="180"/>
<shape type="text" topLeftX="75" topLeftY="345" width="240" height="30">
<content><p><strong><span color="rgb(15,23,42)" fontSize="18">特性一</span></strong></p></content>
</shape>
<shape type="text" topLeftX="75" topLeftY="380" width="240" height="90">
<content><p><span color="rgb(71,85,105)" fontSize="14">简短描述文案,控制在两行以内。</span></p></content>
</shape>
<!-- 卡片 2复制卡片 1shape/img 的 topLeftX 改为 345 / 360 -->
<!-- 卡片 3复制卡片 1shape/img 的 topLeftX 改为 630 / 645 -->
</data>
</slide>
```
### 左右分栏(图在左,文在右)
图框 360×540**2:3 竖幅**建议原图2:3 或 3:4 竖幅(人像照、产品竖拍、海报)
> 如果你只有横幅图,不要硬塞进这个竖框 —— 改用"顶部横幅图 + 下方文字"的版式(把这里的图框改成 960×240 横条放在顶部)。
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="rgb(255,255,255)"/></fill></style>
<data>
<!-- 图框 360×540 = 2:3原图建议 2:3 或 3:4 竖幅 -->
<img src="@./your-portrait.jpg" topLeftX="0" topLeftY="0" width="360" height="540"/>
<shape type="text" topLeftX="410" topLeftY="80" width="490" height="50">
<content><p><strong><span color="rgb(15,23,42)" fontSize="30">场景标题</span></strong></p></content>
</shape>
<line startX="410" startY="140" endX="490" endY="140">
<border color="rgb(59,130,246)" width="3"/>
</line>
<shape type="text" topLeftX="410" topLeftY="160" width="490" height="50">
<content><p><span color="rgb(71,85,105)" fontSize="16">一句话描述这个场景的价值。</span></p></content>
</shape>
<shape type="text" topLeftX="410" topLeftY="230" width="490" height="250">
<content textType="body" lineSpacing="multiple:1.8">
<ul>
<li><p><span color="rgb(51,65,85)" fontSize="15">要点一</span></p></li>
<li><p><span color="rgb(51,65,85)" fontSize="15">要点二</span></p></li>
<li><p><span color="rgb(51,65,85)" fontSize="15">要点三</span></p></li>
</ul>
</content>
</shape>
</data>
</slide>
```
## 深色结尾页
```xml

View File

@@ -219,6 +219,20 @@
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`
`src` 只接受两种值:
| `src` 形式 | 说明 |
|---|---|
| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX` | 通过 `slides +media-upload` 上传后返回的 token |
| `@<本地路径>`(如 `@./assets/chart.png` | **仅在 `slides +create --slides` 中可用**CLI 会自动上传该文件并替换为 file_token |
> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。
本地图片的两种姿势:
- **新建带图 PPT**`+create --slides` 里直接写 `src="@./pic.png"`CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
- **给已有 PPT 加带图新页**:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token再用 token 写进 `xml_presentation.slide create` 的 XML
### `<icon>`
```xml

View File

@@ -132,6 +132,10 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
```
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
> **注意**`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。
### icon
```xml

View File

@@ -12,7 +12,9 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
> **搜索技巧**如果用户的查询只指定了任务名称(例如“完成任务龙虾一号”),请直接使用 `+get-my-tasks --query "龙虾一号"` 命令搜索(不要带 `--complete` 参数,这样可以同时搜索未完成和已完成的任务)
> **任务搜索技巧**先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**查询关键字**(例如任务名称、关键词、片段描述)。如果用户特地指定使用搜索 skill或明确给出了任务查询关键字则目标是**任务**时优先使用 `+search`。如果用户没有特地指定使用搜索 skill且意图里没有查询关键字只有范围条件例如“今年以来”“已完成”“由我创建”“我关注的”并且使用 `+search` 与 `+get-related-tasks` / `+get-my-tasks` 都能达到目的时,应优先使用列表型能力,而不是搜索型能力。其中,“与我相关 / 我关注的 / 由我创建”等优先考虑 `+get-related-tasks`;“我负责的 / 分配给我”的列表优先考虑 `+get-my-tasks`。不要把时间范围词(例如“今年以来”)本身误当成 `query` 去走搜索
> **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill或明确给出了清单查询关键字则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill且意图里没有查询关键字只有范围条件例如“由我创建的任务清单”“今年以来创建的清单”并且使用搜索或原生列取清单都能达到目的时应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。
> **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。
> **用户身份识别**在用户身份user identity场景下如果用户提到了“我”例如“分配给我”、“由我创建”请默认获取当前登录用户的 `open_id` 作为对应的参数值。
> **术语理解**:如果用户提到 “todo”待办应当思考其是否是指“task”任务并优先尝试使用本 Skill 提供的命令来处理。
> **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。
@@ -24,7 +26,8 @@ metadata:
> **查询注意**
> 1. 在输出任务详情时,如果需要渲染负责人、创建人等人员字段,除了展示 `id` (例如 open_id) 外,还必须通过其他方式(例如调用通讯录技能)尝试获取并展示这个人的真实名字,以便用户更容易识别。
> 2. 在输出任务详情时,如果需要渲染创建时间、截止时间等字段需要使用本地时区来渲染格式为2006-01-02 15:04:05
> 2. 在输出清单详情时,如果需要渲染 owner、member、角色成员等人员字段也必须像任务成员展示一样除了展示 `id` 外,尽量解析并展示对应人员的真实名字
> 3. 在输出任务或清单详情时如果需要渲染创建时间、截止时间等字段需要使用本地时区来渲染格式为2006-01-02 15:04:05
> **Task GUID 定义**
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
@@ -41,7 +44,12 @@ metadata:
- [`+followers`](./references/lark-task-followers.md) — Manage task followers
- [`+reminder`](./references/lark-task-reminder.md) — Manage task reminders
- [`+get-my-tasks`](./references/lark-task-get-my-tasks.md) — List tasks assigned to me
- [`+get-related-tasks`](./references/lark-task-get-related-tasks.md) — List tasks related to me
- [`+search`](./references/lark-task-search.md) — Search tasks
- [`+subscribe-event`](./references/lark-task-subscribe-event.md) — Subscribe to task events
- [`+set-ancestor`](./references/lark-task-set-ancestor.md) — Set or clear a task ancestor
- [`+tasklist-create`](./references/lark-task-tasklist-create.md) — Create a tasklist and batch add tasks
- [`+tasklist-search`](./references/lark-task-tasklist-search.md) — Search tasklists
- [`+tasklist-task-add`](./references/lark-task-tasklist-task-add.md) — Add existing tasks to a tasklist
- [`+tasklist-members`](./references/lark-task-tasklist-members.md) — Manage tasklist members

View File

@@ -38,7 +38,7 @@ lark-cli task +create --summary "Test Task" --dry-run
## Workflow
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status --json` or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
2. Execute `lark-cli task +create --summary "..." ...`
3. Report the result: task ID and summary.

View File

@@ -0,0 +1,53 @@
# task +get-related-tasks
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
>
> **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.**
>
> **Pagination / Time Cursor Rule:**
> In `+get-related-tasks`, `page_token` is the task `updated_at` cursor in microseconds.
>
> **Execution Priority:**
> 1. If the request contains a start/end time boundary (for example, "今年以来", "最近一个月", "从 3 月 1 日开始"), first convert the **start time** boundary to a microsecond `page_token` and query from that token.
> 2. Continue pagination using returned `page_token` until `has_more=false`, but never exceed 40 total page fetches.
> 3. Do NOT default to `--page-all` for time-bounded queries.
>
> Only use `--page-all` from the beginning when:
> 1. the user explicitly asks for a full scan of all related tasks, or
> 2. no time boundary can be inferred from the request.
List tasks related to the current user.
## Recommended Commands
```bash
# List all related tasks
lark-cli task +get-related-tasks
# List incomplete related tasks starting from a page token
lark-cli task +get-related-tasks --include-complete=false --page-token "1752730590582902"
# Show only tasks created by me
lark-cli task +get-related-tasks --created-by-me
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--include-complete=<bool>` | No | Default behavior includes completed tasks. Set to `false` to keep only incomplete tasks. |
| `--page-all` | No | Automatically paginate through all pages (max 40). |
| `--page-limit <int>` | No | Max page limit (default 20). |
| `--page-token <string>` | No | Start from the specified page token. This token is the task's last update time cursor in microseconds. |
| `--created-by-me` | No | Keep only tasks whose creator is the current user. This is a client-side filter applied after fetching related-task pages. |
| `--followed-by-me` | No | Keep only tasks followed by the current user. This is a client-side filter applied after fetching related-task pages. |
> **Page Token Note:** In `+get-related-tasks`, the `page_token` is a microsecond-level cursor representing the task's last update time. For example, `1752730590582902` should be treated as an updated-at cursor, not a task ID.
>
> **Pagination Note for Client-side Filters:** When `--created-by-me` or `--followed-by-me` is used, filtering happens locally after each upstream related-task page is fetched. The returned `has_more` and `page_token` still describe the upstream cursor, so later pages may contain more matching tasks, or may contain none.
## Workflow
1. Determine whether the user needs all related tasks or a filtered subset.
2. Execute `lark-cli task +get-related-tasks ...`
3. Report the matching tasks and, if present, the next `page_token`.

View File

@@ -0,0 +1,41 @@
# task +search
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
>
> **⚠️ Note:** This API must be called with a user identity. **Do NOT use an app identity, otherwise the call will fail.**
Search tasks by keyword and optional filters.
## Recommended Commands
```bash
# Search by keyword
lark-cli task +search --query "test"
# Search incomplete tasks assigned to specific users
lark-cli task +search --assignee "ou_xxx,ou_yyy" --completed=false
# Search by due time range
lark-cli task +search --query "release" --due "-1d,+7d"
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--query <string>` | No | Search keyword. If omitted, at least one filter must be provided. |
| `--creator <ids>` | No | Creator open_ids, comma-separated. |
| `--assignee <ids>` | No | Assignee open_ids, comma-separated. |
| `--follower <ids>` | No | Follower open_ids, comma-separated. |
| `--completed=<bool>` | No | Filter by completion state. |
| `--due <range>` | No | Due time range in `start,end` form. Each side supports ISO/date/relative/ms input. |
| `--page-token <string>` | No | Page token for pagination. |
| `--page-all` | No | Automatically paginate through all pages (max 40). |
| `--page-limit <int>` | No | Max page limit (default 20). |
## Workflow
1. Build the keyword and filters from the user's request.
2. Execute `lark-cli task +search ...`
3. Report the matched tasks and include the next `page_token` if more results exist.

View File

@@ -0,0 +1,32 @@
# task +set-ancestor
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
Set a parent task for a task, or clear the parent to make it independent.
## Recommended Commands
```bash
# Set a parent task
lark-cli task +set-ancestor --task-id "guid_1" --ancestor-id "guid_2"
# Clear the parent task
lark-cli task +set-ancestor --task-id "guid_1"
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid>` | Yes | The task GUID to update. |
| `--ancestor-id <guid>` | No | The parent task GUID. Omit it to clear the ancestor. |
## Workflow
1. Confirm the child task and, if applicable, the ancestor task.
2. Execute `lark-cli task +set-ancestor ...`
3. Report the updated task GUID and whether the ancestor was set or cleared.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -0,0 +1,86 @@
# task +subscribe-event
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
>
> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe tasks the **application is responsible for**.
Subscribe task update events with the current identity.
This shortcut is different from `event +subscribe`:
- `task +subscribe-event` registers task-event access for the **current identity**
- with `--as user`, it subscribes the **current user** to task events for tasks they created, are responsible for, or follow
- with `--as bot`, it subscribes using the **application identity** for tasks the application is responsible for
The task event type is:
```text
task.task.update_user_access_v2
```
Within this event, task changes are represented by commit types (string values). Deduped list:
```text
task_assignees_update
task_completed_update
task_create
task_deleted
task_desc_update
task_followers_update
task_reminders_update
task_start_due_update
task_summary_update
```
Event payload shape (example):
```json
{
"event_id": "evt_xxx",
"event_types": ["task_summary_update"],
"task_guid": "task_guid_xxx",
"timestamp": "1775793266152",
"type": "task.task.update_user_access_v2"
}
```
- `type`: event type, should be `task.task.update_user_access_v2`
- `event_id`: unique event id (useful for dedup)
- `event_types`: list of commit types (see the deduped list above)
- `task_guid`: the task GUID that changed
- `timestamp`: event timestamp (ms)
In practice, this means:
- with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following
- with `--as bot`, the subscription covers tasks the application is responsible for
To actually receive the subscribed events, use the standard event WebSocket receiver:
```bash
lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compact --quiet
```
The full flow is:
1. Register the subscription with `lark-cli task +subscribe-event [--as user|bot]`
2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...`
## Recommended Commands
```bash
lark-cli task +subscribe-event
```
# Subscribe with app identity
lark-cli task +subscribe-event --as bot
## Parameters
This shortcut has no additional parameters.
## Workflow
1. Confirm whether the user wants to subscribe with `user` identity or `bot` identity.
2. Execute `lark-cli task +subscribe-event`
3. Report whether the subscription succeeded, and clarify which identity the subscription applies to.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

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