Compare commits

..

24 Commits

Author SHA1 Message Date
zhengkenghong
60e6bc2f3b feat(whiteboard): route node update through batch_update
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:48 +08:00
zhengkenghong
1fd29e75a6 feat: add whiteboard node shortcuts
Add whiteboard node create, update, and delete shortcuts with focused dry-run and unit coverage. Document the new node-level operations in the embedded lark-whiteboard skill and split node shortcut unit tests by command for maintainability.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:45 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
chenxingyang1019
41692b7041 feat(apps): add cache debug commands (+cache-get/-delete/-clear) (#1896)
* feat(apps): add cache debug commands (+cache-get/-delete/-clear)

Add three apps-domain cache debug shortcuts for inspecting/clearing an app's
runtime cache:
- +cache-get: read a business key's value + metadata (hit/miss)
- +cache-delete: delete a single key (idempotent, write)
- +cache-clear: clear all cache in an environment (high-risk-write, --yes)

value renders raw on --format json, deserialized on --format pretty;
value_size_bytes is computed CLI-side; --environment auto-selects the branch
when omitted. Includes unit tests (hit/miss/dry-run/confirmation) and the
lark-apps cache skill reference.

* fix(apps): normalize cache numeric output fields and tidy comments

Follow-up hardening for the cache debug commands (+cache-get/-delete/-clear):

- Normalize ttl_ms / deleted_key_count via a new cacheInt() helper so
  --format json emits a stable JSON number (or null) regardless of whether
  the server sends the value as a number or a string. Aligns with the
  repo convention that numeric wire fields may arrive as strings; previously
  these were passed through raw, leaving the output type at the server's mercy.
- Add unit tests locking the string-wire -> JSON number contract for both
  cache-get ttl_ms and cache-delete deleted_key_count.
- Tidy two comments: soften cacheBool's speculative "historical wire form"
  claim to a defensive-tolerance note, and drop implementation jargon from
  cache-delete's risk-level rationale.
2026-07-31 14:13:56 +08:00
dc-bytedance
b79827d60a fix: drop stale target version from root upgrade prompt (#2100) 2026-07-31 12:45:43 +08:00
zhaojiaxing-coding
0f35676a28 feat(drive): extend permission shortcuts for Miaoda (#2070)
* feat(drive): support Miaoda apps in permission shortcuts

Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit.

Key features:

- Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting

- Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support

- Align skill guidance and unit/dry-run coverage with the new resource type

* test(drive): cover apps permission target validation

Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries.

Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior.

* fix(drive): parse permission markers from URL paths

Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource.

Key fixes:

- Match document and apps markers only against the parsed URL path

- Reject malformed URLs with a typed --token validation error

- Cover /page/ markers found only in query strings or fragments

* docs(skills): redact Miaoda page token example

Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation.

* fix(drive): harden permission target resolution

Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource.

Key fixes:

- Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes

- Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind

- Add unit and dry-run E2E regressions plus aligned permission-apply guidance
2026-07-31 12:16:22 +08:00
wangweiming-01
946964e093 fix(drive): use title for default download filename (#2089) 2026-07-31 12:12:11 +08:00
HanShaoshuai-k
cfe76ad56a ci: add protected public domain allowlists (#2111)
Co-authored-by: HanShaoshuai-k <268785735+HanShaoshuai-k@users.noreply.github.com>
2026-07-31 11:02:04 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
calendar-assistant
1f565a290b docs(calendar): warn against container-default timezone in time conversion (#2104)
Agents dropping to the raw `calendar events create/patch` API must convert
wall-clock time to Unix timestamps themselves. In UTC containers this silently
yields an 8-hour offset. Require explicit ISO 8601 offsets on +create/+update
--start/--end, and warn that raw-API timestamp conversion must specify the
target timezone instead of relying on the container default.
2026-07-30 14:06:14 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
170 changed files with 21122 additions and 872 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -2,6 +2,27 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
@@ -1701,6 +1722,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

View File

@@ -159,8 +159,7 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(acct.Brand)))
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
})
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
@@ -26,6 +27,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +57,9 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}
@@ -197,14 +202,27 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
// ShortcutHeaders extracts Shortcut info from the context and returns
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
func ShortcutHeaders(ctx context.Context) http.Header {
name, ok := ShortcutNameFromContext(ctx)
if !ok {
return nil
}
h := make(http.Header)
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
if name, ok := ShortcutNameFromContext(ctx); ok {
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
}
}
if name, value := extraHeaderFromEnv(); name != "" && value != "" {
h.Set(name, value)
}
if len(h) == 0 {
return nil
}
return h
}
func extraHeaderFromEnv() (string, string) {
name := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderName))
value := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderValue))
if name == "" || value == "" {
return "", ""
}
return name, value
}

View File

@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// Agent headers injected via BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -3,7 +3,12 @@
package core
import "strings"
import (
"os"
"strings"
"github.com/larksuite/cli/internal/envvars"
)
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
@@ -61,5 +66,8 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
func ResolveOpenBaseURL(brand LarkBrand) string {
if override := strings.TrimRight(strings.TrimSpace(os.Getenv(envvars.CliOpenBaseURL)), "/"); override != "" {
return override
}
return ResolveEndpoints(brand).Open
}

View File

@@ -58,6 +58,13 @@ func TestResolveOpenBaseURL(t *testing.T) {
}
}
func TestResolveOpenBaseURL_EnvOverride(t *testing.T) {
t.Setenv("LARKSUITE_CLI_OPEN_BASE_URL", "https://open.feishu-boe.cn/")
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu-boe.cn" {
t.Errorf("ResolveOpenBaseURL(feishu with env override) = %q", got)
}
}
func TestParseBrand(t *testing.T) {
cases := []struct {
in string

View File

@@ -22,6 +22,10 @@ const (
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliOpenBaseURL = "LARKSUITE_CLI_OPEN_BASE_URL"
CliExtraHeaderName = "LARKSUITE_CLI_EXTRA_HEADER_NAME"
CliExtraHeaderValue = "LARKSUITE_CLI_EXTRA_HEADER_VALUE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
CliCAPath = "LARKSUITE_CLI_CA_PATH"

View File

@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
}
}

View File

@@ -38,6 +38,10 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
## Public Domain Allowlists
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
## Semantic Blocker Policy
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:

View File

@@ -0,0 +1,24 @@
# Exact test-only hostnames. Keep sorted.
abc.feishu.cn
attacker.example.com
bytedance.feishu.cn
cdn.feishu.cn
evil.example.com
example.feishu.cn
example.larkoffice.com
example.larksuite.com
feishu.cn
feishu.doubao.com
gateway.docker.internal
host.containers.internal
host.docker.internal
host.lima.internal
lf3-static.bytednsdoc.com
meetings.feishu.cn
meetings.larksuite.com
p3-lark-file.byteimg.com
passport.feishu.cn
sample.feishu.cn
x.feishu.cn
xxx.feishu.cn
xxx.larksuite.com

View File

@@ -0,0 +1,18 @@
# Exact public hostnames. Keep sorted.
accounts.feishu.cn
accounts.larksuite.com
applink.feishu.cn
applink.larksuite.com
ark.ap-southeast.bytepluses.com
github.com
larkoffice.com
lf-larkemail.bytetos.com
mcp.feishu.cn
mcp.larksuite.com
open.feishu.cn
open.larksuite.com
registry.npmjs.org
registry.npmmirror.com
sf16-sg.tiktokcdn.com
www.feishu.cn
www.larksuite.com

View File

@@ -19,7 +19,7 @@ lint/
├── lintapi/ # shared types every domain returns
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
└── errscontract/ # first domain: typed-error contract guards
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
├── runner.go
├── typecheck.go
├── violation.go # local type aliases to lintapi
@@ -30,16 +30,19 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
── scan_test.go
└── domaincontract/ # resolver ownership + approved public hostname policy
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
── unapproved.go # Go AST/type-aware hostname extraction
├── policy.go # exact public/fixture allowlist validation
├── diff.go # added-line attribution
└── *_test.go
```
## Endpoint domain contract (`domaincontract`)
`domaincontract` is a syntax-level regression guard for the resolver-owned
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
files it rejects:
`domaincontract` contains two complementary Go source guards.
The resolver-ownership guard rejects:
- string literals containing a resolver-owned host FQDN
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
@@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
the guard fails the lint module's tests.
This is not a general outbound-URL or data-flow analyzer. It does not inspect
non-Go assets, hosts assembled from string fragments, SDK constructor option
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
remain the backstop for those cases.
The approved-domain guard parses every Git-tracked Go file in full. In CI,
unapproved-host findings are limited to values whose expressions intersect an
added line; policy validation and unused-entry checks remain repository-wide.
It rejects an exact hostname unless it is present in one of:
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
- `internal/qualitygate/config/allowlists/public-domains.txt`, for production
and test code; or
- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for
`*_test.go`, the repository-root `tests/`, and any `testdata/` (never
`skills/`).
RFC 2606 example/test names are accepted independently of those lists. This
includes the reserved `.test`, `.example`, `.invalid`, and `.localhost`
namespaces and the exact names `example.com`, `example.net`, and `example.org`;
they are safe placeholders rather than supported public endpoints.
High-confidence evidence is deliberately limited to static string expressions
assigned to `host`, `hostname`, or `domain` semantic names (including common
case/plural forms and collections), plus static strings whose entire value is
an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals,
escapes, compile-time concatenation, constant references, grouped declarations,
multi-value assignments, and multiline expressions. Bare domain-shaped strings
without hostname semantics are not blocked.
Sequence values are scanned individually. For a hostname-semantic map, a key or
value is evidence only when it is the sole hostname-shaped side of that entry;
ambiguous string-to-string entries are not guessed. Struct fields use Go type
information so known non-network `Host` / `Domain` fields do not become hostname
evidence merely because an enum or command category contains a dot.
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
hostnames, and have a current in-scope use. See
`internal/qualitygate/config/README.md` for admission and approval rules.
This is not a general outbound-URL or cross-language data-flow analyzer. It does
not inspect non-Go assets or dynamically constructed values.
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
than hardcoding the host elsewhere.
## Running
```bash
# from the repo root (one level above lint/)
# PR-scoped scan from the repo root (one level above lint/)
go run -C lint . --changed-from <base-revision> ..
# Full inventory (also reports historical unapproved hostnames)
go run -C lint . ..
```
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
import "github.com/larksuite/cli/lint/lintapi"
// ScanRepo walks root and returns every violation produced by this
// domain's checks. Domains MUST return []lintapi.Violation so the
// top-level dispatcher can aggregate uniformly.
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
type ScanOptions struct {
ChangedFrom string
}
// ScanRepoWithOptions walks root and returns every violation produced
// by this domain's checks. Domains MUST return []lintapi.Violation so
// the top-level dispatcher can aggregate uniformly.
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
```
3. Per-rule files are named `rule_<name>.go` with sibling
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
```go
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepo},
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
```

171
lint/domaincontract/diff.go Normal file
View File

@@ -0,0 +1,171 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type addedLineRange struct {
Start int
End int
}
type changedGoPath struct {
Old string
New string
}
var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) {
if from == "" {
return nil, nil
}
names, err := gitCommandOutput(
root,
"diff",
"--name-status",
"-z",
"--find-renames",
"--diff-filter=ACMR",
from+"...HEAD",
"--",
)
if err != nil {
return nil, fmt.Errorf("list changed Go files: %w", err)
}
paths, err := parseChangedGoPaths(names)
if err != nil {
return nil, fmt.Errorf("parse changed Go files: %w", err)
}
out := map[string][]addedLineRange{}
for _, path := range paths {
args := []string{
"diff",
"--unified=0",
"--no-color",
"--no-ext-diff",
"--find-renames",
"--diff-filter=ACMR",
from + "...HEAD",
"--",
}
if path.Old != path.New {
args = append(args, path.Old)
}
args = append(args, path.New)
patch, err := gitCommandOutput(root, args...)
if err != nil {
return nil, fmt.Errorf("read diff for %s: %w", path.New, err)
}
ranges, err := parseAddedLineRanges(patch)
if err != nil {
return nil, fmt.Errorf("parse diff for %s: %w", path.New, err)
}
out[path.New] = ranges
}
return out, nil
}
func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) {
fields := bytes.Split(raw, []byte{0})
var out []changedGoPath
for i := 0; i < len(fields); {
status := string(fields[i])
i++
if status == "" {
break
}
if i >= len(fields) || len(fields[i]) == 0 {
return nil, fmt.Errorf("truncated name-status record")
}
oldPath := filepath.ToSlash(string(fields[i]))
i++
newPath := oldPath
if status[0] == 'R' || status[0] == 'C' {
if i >= len(fields) || len(fields[i]) == 0 {
return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath)
}
newPath = filepath.ToSlash(string(fields[i]))
i++
if status[0] == 'C' {
// A copy introduces every destination line. Diff only the new
// path so Git presents it as an added file rather than a
// metadata-only copy with no added-line ranges.
oldPath = newPath
}
}
if !strings.HasSuffix(newPath, ".go") {
continue
}
out = append(out, changedGoPath{Old: oldPath, New: newPath})
}
return out, nil
}
func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) {
var out []addedLineRange
for _, raw := range bytes.Split(patch, []byte{'\n'}) {
line := string(raw)
if !strings.HasPrefix(line, "@@") {
continue
}
match := unifiedHunkRE.FindStringSubmatch(line)
if match == nil {
return nil, fmt.Errorf("unsupported unified hunk header %q", line)
}
start, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("parse added start line in %q: %w", line, err)
}
count := 1
if match[2] != "" {
count, err = strconv.Atoi(match[2])
if err != nil {
return nil, fmt.Errorf("parse added line count in %q: %w", line, err)
}
}
if count == 0 {
continue
}
out = append(out, addedLineRange{Start: start, End: start + count - 1})
}
return out, nil
}
func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) {
for _, r := range ranges {
if start <= r.End && end >= r.Start {
if start > r.Start {
return start, true
}
return r.Start, true
}
}
return 0, false
}
func gitCommandOutput(root string, args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.Output()
if err == nil {
return out, nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
if stderr != "" {
return nil, fmt.Errorf("%w: %s", err, stderr)
}
}
return nil, err
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import "testing"
func TestParseChangedGoPaths(t *testing.T) {
raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00")
got, err := parseChangedGoPaths(raw)
if err != nil {
t.Fatal(err)
}
want := []changedGoPath{
{Old: "changed.go", New: "changed.go"},
{Old: "old.go", New: "renamed.go"},
{Old: "copied.go", New: "copied.go"},
}
if len(got) != len(want) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("paths = %#v, want %#v", got, want)
}
}
}
func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) {
if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil {
t.Fatal("expected truncated rename error")
}
}
func TestParseAddedLineRanges(t *testing.T) {
patch := []byte(`diff --git a/x.go b/x.go
index 1111111..2222222 100644
--- a/x.go
+++ b/x.go
@@ -2,0 +3,2 @@
+first
+second
@@ -10 +12 @@
-old
+new
@@ -20 +21,0 @@
-deleted
`)
got, err := parseAddedLineRanges(patch)
if err != nil {
t.Fatal(err)
}
want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}}
if len(got) != len(want) {
t.Fatalf("ranges = %#v, want %#v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("ranges = %#v, want %#v", got, want)
}
}
}
func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) {
if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil {
t.Fatal("expected unsupported hunk error")
}
}
func TestFirstAddedLineInSpan(t *testing.T) {
ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}}
tests := []struct {
start, end int
line int
ok bool
}{
{start: 1, end: 4, ok: false},
{start: 4, end: 6, line: 5, ok: true},
{start: 6, end: 9, line: 6, ok: true},
{start: 8, end: 12, line: 10, ok: true},
}
for _, tc := range tests {
line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end)
if line != tc.line || ok != tc.ok {
t.Errorf(
"firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)",
tc.start,
tc.end,
line,
ok,
tc.line,
tc.ok,
)
}
}
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt"
fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt"
)
type domainPolicyEntry struct {
Host string
File string
Line int
}
type domainPolicy struct {
Public map[string]domainPolicyEntry
Fixtures map[string]domainPolicyEntry
}
// isReservedExampleHostname recognizes only names reserved by RFC 2606 for
// examples, testing, invalid-name examples, and localhost use. These names are
// safe source placeholders and are policy exceptions, not supported public
// endpoints.
func isReservedExampleHostname(host string) bool {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
switch host {
case "example.com", "example.net", "example.org":
return true
}
labels := strings.Split(host, ".")
switch labels[len(labels)-1] {
case "test", "example", "invalid", "localhost":
return true
default:
return false
}
}
func loadDomainPolicy(root string) (domainPolicy, error) {
public, err := loadDomainList(root, publicDomainsPath)
if err != nil {
return domainPolicy{}, err
}
fixtures, err := loadDomainList(root, fixtureDomainsPath)
if err != nil {
return domainPolicy{}, err
}
for host, entry := range fixtures {
if publicEntry, ok := public[host]; ok {
return domainPolicy{}, fmt.Errorf(
"%s:%d: hostname %q is already listed at %s:%d",
entry.File, entry.Line, host, publicEntry.File, publicEntry.Line,
)
}
}
return domainPolicy{Public: public, Fixtures: fixtures}, nil
}
func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) {
path := filepath.Join(root, filepath.FromSlash(rel))
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err)
}
defer file.Close()
entries := map[string]domainPolicyEntry{}
var previous string
scanner := bufio.NewScanner(file)
for line := 1; scanner.Scan(); line++ {
host := strings.TrimSpace(scanner.Text())
if host == "" || strings.HasPrefix(host, "#") {
continue
}
if host != strings.ToLower(host) {
return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host)
}
if err := validatePolicyHostname(host); err != nil {
return nil, fmt.Errorf("%s:%d: %w", rel, line, err)
}
if previous != "" && host <= previous {
return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host)
}
entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line}
previous = host
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("%s: domain list must not be empty", rel)
}
return entries, nil
}
func validatePolicyHostname(host string) error {
if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") {
return fmt.Errorf("invalid exact hostname %q", host)
}
labels := strings.Split(host, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("invalid exact hostname %q", host)
}
for _, r := range label {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return fmt.Errorf("invalid exact hostname %q", host)
}
}
if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") {
return fmt.Errorf("invalid exact hostname %q", host)
}
return nil
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"strings"
"testing"
)
func TestLoadDomainPolicy(t *testing.T) {
root := t.TempDir()
writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n")
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
policy, err := loadDomainPolicy(root)
if err != nil {
t.Fatal(err)
}
if len(policy.Public) != 2 || len(policy.Fixtures) != 1 {
t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures))
}
if policy.Public["api.example.com"].Line != 2 {
t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line)
}
}
func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) {
tests := []struct {
name string
public string
fixtures string
want string
}{
{
name: "uppercase",
public: "API.example.com\n",
fixtures: "fixture.example.com\n",
want: "must be lowercase",
},
{
name: "unsorted",
public: "www.example.com\napi.example.com\n",
fixtures: "fixture.example.com\n",
want: "unique and sorted",
},
{
name: "duplicate",
public: "api.example.com\napi.example.com\n",
fixtures: "fixture.example.com\n",
want: "unique and sorted",
},
{
name: "wildcard",
public: "*.example.com\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "scheme",
public: "https://example.com\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "path",
public: "api.example.com/v1\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "port",
public: "api.example.com:443\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "cross-list duplicate",
public: "api.example.com\n",
fixtures: "api.example.com\n",
want: "already listed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
writeFile(t, root, publicDomainsPath, tc.public)
writeFile(t, root, fixtureDomainsPath, tc.fixtures)
_, err := loadDomainPolicy(root)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestReservedExampleHostname(t *testing.T) {
for _, host := range []string{
"example.com",
"example.net",
"example.org",
"example.test",
"docs.example",
"missing.invalid",
"service.localhost",
} {
if !isReservedExampleHostname(host) {
t.Errorf("%q should be a reserved example hostname", host)
}
}
for _, host := range []string{
"attacker.example.com",
"example.dev",
"private.corp.internal",
} {
if isReservedExampleHostname(host) {
t.Errorf("%q must still require policy approval", host)
}
}
}

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package domaincontract guards the Go CLI against direct reuse of the current
// resolver-owned host FQDNs outside core.ResolveEndpoints.
// Package domaincontract guards resolver ownership and rejects newly introduced
// static Go hostnames that are not covered by the repository domain policy.
package domaincontract
import (
@@ -11,6 +11,7 @@ import (
"go/token"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
return false
}
// ScanRepo walks production .go files under root and flags string literals
// containing a forbidden resolver host outside the allowlist. Comments and
// _test.go files are not scanned.
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
// historical unapproved domains are not attributed to an unrelated change.
func ScanRepo(root string) ([]lintapi.Violation, error) {
return ScanRepoWithOptions(root, ScanOptions{})
}
type ScanOptions struct {
ChangedFrom string
}
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) {
out, err := scanHardcodedEndpoints(root)
if err != nil {
return nil, err
}
domainViolations, err := scanUnapprovedDomains(root, opts)
if err != nil {
return nil, err
}
out = append(out, domainViolations...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) {
var out []lintapi.Violation
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {

View File

@@ -0,0 +1,911 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"fmt"
"go/ast"
"go/constant"
"go/parser"
"go/token"
"go/types"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/lint/lintapi"
"golang.org/x/tools/go/packages"
)
const (
unapprovedDomainRule = "unapproved-domain"
unusedDomainRule = "domain-allowlist-unused"
incompleteDomainRule = "domain-scan-incomplete"
)
type typedGoFile struct {
File *ast.File
Fset *token.FileSet
Info *types.Info
}
type domainEvidence struct {
Host string
Kind string
Expr ast.Expr
}
type evidenceKey struct {
Host string
Start, End token.Pos
}
type fileDomainScan struct {
File *ast.File
Fset *token.FileSet
Info *types.Info
Evidence []domainEvidence
TypeInfoRequired []ast.Expr
seen map[evidenceKey]bool
parents map[ast.Node]ast.Node
}
type collectionCompositeKind uint8
const (
notCollectionComposite collectionCompositeKind = iota
sequenceComposite
mapComposite
)
type hostnameFieldID struct {
Type string
Field string
}
var nonNetworkHostnameFields = map[hostnameFieldID]bool{
{Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true,
{Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true,
}
func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve repository root: %w", err)
}
publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath))
if _, err := os.Stat(publicPath); err != nil {
if os.IsNotExist(err) {
if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) {
return nil, nil
}
}
return nil, fmt.Errorf("domain policy unavailable: %w", err)
}
policy, err := loadDomainPolicy(root)
if err != nil {
return nil, err
}
added, err := changedGoLineRanges(root, opts.ChangedFrom)
if err != nil {
return nil, err
}
typed, typeLoadErr := loadTypedGoFiles(root)
goFiles, err := trackedGoFiles(root)
if err != nil {
return nil, err
}
observedPublic := map[string]bool{}
observedFixtures := map[string]bool{}
inventoryComplete := typeLoadErr == nil
var out []lintapi.Violation
parseFailureReported := false
typeInfoGapReported := false
for _, rel := range goFiles {
path := filepath.Join(root, filepath.FromSlash(rel))
parsedFset := token.NewFileSet()
parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0)
if parseErr != nil {
inventoryComplete = false
if opts.ChangedFrom == "" {
out = append(out, incompleteDomainViolation(rel, parseErr))
parseFailureReported = true
} else if _, changed := added[rel]; changed {
out = append(out, incompleteDomainViolation(rel, parseErr))
parseFailureReported = true
}
continue
}
tf, ok := typed[filepath.Clean(path)]
if !ok {
tf = typedGoFile{File: parsedFile, Fset: parsedFset}
}
scan := newFileDomainScan(tf)
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
if len(scan.TypeInfoRequired) > 0 {
// Inventory completeness is a property of the whole HEAD. Whether
// this PR owns an incomplete-scan diagnostic is decided separately
// by the added-line intersection below.
inventoryComplete = false
}
for _, expr := range scan.TypeInfoRequired {
start := tf.Fset.Position(expr.Pos()).Line
end := tf.Fset.Position(expr.End()).Line
line := start
if opts.ChangedFrom != "" {
var intersects bool
line, intersects = firstAddedLineInSpan(added[rel], start, end)
if !intersects {
continue
}
}
typeInfoGapReported = true
out = append(out, incompleteDomainViolationAt(
rel,
line,
fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"),
))
break
}
fixture := isDomainFixturePath(rel)
// The detector's own policy literals and contract corpus may be
// scanned, but they cannot justify keeping an allowlist entry.
policyOwner := strings.HasPrefix(rel, "lint/domaincontract/")
for _, evidence := range scan.Evidence {
if isReservedExampleHostname(evidence.Host) {
continue
}
if _, ok := policy.Public[evidence.Host]; ok {
if !fixture && !policyOwner {
observedPublic[evidence.Host] = true
}
continue
}
if _, ok := policy.Fixtures[evidence.Host]; ok && fixture {
if !policyOwner {
observedFixtures[evidence.Host] = true
}
continue
}
start := tf.Fset.Position(evidence.Expr.Pos()).Line
end := tf.Fset.Position(evidence.Expr.End()).Line
line := start
if opts.ChangedFrom != "" {
var intersects bool
line, intersects = firstAddedLineInSpan(added[rel], start, end)
if !intersects {
continue
}
}
suggestion := "remove the hostname or replace it with an approved public endpoint; " +
"public allowlist additions require evidence and CODEOWNER approval"
if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture {
suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " +
"fixture entries are not approved for production Go code or skills"
}
out = append(out, lintapi.Violation{
Rule: unapprovedDomainRule,
Action: lintapi.ActionReject,
File: rel,
Line: line,
Message: fmt.Sprintf(
"unapproved hostname %q found in %s",
evidence.Host,
evidence.Kind,
),
Suggestion: suggestion,
})
}
}
// A syntax error is also surfaced by go/packages. Prefer the file-specific
// parse diagnostic when one was already reported; otherwise make a
// repository-wide type-loading failure explicit instead of silently
// continuing without the type information required by field evidence.
if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported {
out = append(out, incompleteDomainViolation("go.mod", typeLoadErr))
}
if inventoryComplete {
for host, entry := range policy.Public {
if !observedPublic[host] {
out = append(out, unusedDomainViolation(entry))
}
}
for host, entry := range policy.Fixtures {
if !observedFixtures[host] {
out = append(out, unusedDomainViolation(entry))
}
}
}
return out, nil
}
func trackedGoFiles(root string) ([]string, error) {
out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go")
if err != nil {
return nil, fmt.Errorf("list tracked Go files: %w", err)
}
var files []string
for _, raw := range strings.Split(string(out), "\x00") {
if raw == "" {
continue
}
rel := filepath.ToSlash(raw)
if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") {
continue
}
files = append(files, rel)
}
return files, nil
}
func loadTypedGoFiles(root string) (map[string]typedGoFile, error) {
moduleDirs, err := trackedGoModuleDirs(root)
if err != nil {
return nil, err
}
out := map[string]typedGoFile{}
var firstLoadErr error
var loadErrCount int
for _, moduleDir := range moduleDirs {
moduleRoot := root
if moduleDir != "." {
moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir))
}
files, err := loadTypedGoModule(moduleRoot)
for path, file := range files {
out[path] = file
}
if err != nil {
loadErrCount++
if firstLoadErr == nil {
firstLoadErr = err
}
}
}
if loadErrCount == 1 {
return out, firstLoadErr
}
if loadErrCount > 1 {
return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1)
}
return out, nil
}
func trackedGoModuleDirs(root string) ([]string, error) {
raw, err := gitCommandOutput(root, "ls-files", "-z")
if err != nil {
return nil, fmt.Errorf("list tracked Go modules: %w", err)
}
var dirs []string
for _, path := range strings.Split(string(raw), "\x00") {
path = filepath.ToSlash(path)
if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") {
continue
}
dir := filepath.ToSlash(filepath.Dir(path))
dirs = append(dirs, dir)
}
return dirs, nil
}
func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) {
fset := token.NewFileSet()
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo,
Dir: moduleRoot,
Fset: fset,
Tests: true,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
return nil, fmt.Errorf("load Go type information: %w", err)
}
out := map[string]typedGoFile{}
var firstPackageErr string
var packageErrCount int
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
if pkg == nil {
return
}
for _, pkgErr := range pkg.Errors {
packageErrCount++
if firstPackageErr == "" {
firstPackageErr = pkgErr.Error()
}
}
if pkg.TypesInfo == nil || pkg.Fset == nil {
return
}
for i, file := range pkg.Syntax {
if i >= len(pkg.CompiledGoFiles) {
break
}
path := filepath.Clean(pkg.CompiledGoFiles[i])
if _, exists := out[path]; exists {
continue
}
out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo}
}
})
if packageErrCount == 1 {
return out, fmt.Errorf("load Go type information: %s", firstPackageErr)
}
if packageErrCount > 1 {
return out, fmt.Errorf(
"load Go type information: %s (and %d more package errors)",
firstPackageErr,
packageErrCount-1,
)
}
return out, nil
}
func newFileDomainScan(file typedGoFile) *fileDomainScan {
return &fileDomainScan{
File: file.File,
Fset: file.Fset,
Info: file.Info,
seen: map[evidenceKey]bool{},
parents: astParentMap(file.File),
}
}
func (s *fileDomainScan) collectSemanticEvidence() {
ast.Inspect(s.File, func(node ast.Node) bool {
switch n := node.(type) {
case *ast.AssignStmt:
if len(n.Lhs) != len(n.Rhs) {
return true
}
for i, lhs := range n.Lhs {
if s.Info == nil &&
potentialHostnameSelectorTarget(lhs) &&
s.hasStaticBareHostnameValue(n.Rhs[i]) {
s.requireTypeInfo(n.Rhs[i])
}
if index, ok := stripParens(lhs).(*ast.IndexExpr); ok {
switch {
case s.isHostnameTarget(index.X):
s.addMapPair(index.Index, n.Rhs[i])
case s.isHostnameMapKey(index.Index):
s.addHostValue(n.Rhs[i], "host assignment")
}
continue
}
if s.isHostnameTarget(lhs) {
s.addHostValue(n.Rhs[i], "host assignment")
}
}
case *ast.ValueSpec:
if len(n.Names) != len(n.Values) {
return true
}
for i, name := range n.Names {
if isHostnameSemanticName(name.Name) {
s.addHostValue(n.Values[i], "host assignment")
}
}
case *ast.KeyValueExpr:
if s.Info == nil && s.keyValueNeedsTypeInfo(n) {
s.requireTypeInfo(n.Value)
}
if s.isHostnameKeyValue(n) {
s.addHostValue(n.Value, "host assignment")
}
}
return true
})
}
func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) {
for _, existing := range s.TypeInfoRequired {
if existing.Pos() == expr.Pos() && existing.End() == expr.End() {
return
}
}
s.TypeInfoRequired = append(s.TypeInfoRequired, expr)
}
func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool {
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return false
}
host, ok := semanticHostname(value)
return ok && !isReservedExampleHostname(host)
}
func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool {
composite, ok := s.parents[pair].(*ast.CompositeLit)
if !ok {
return false
}
if _, explicitMap := composite.Type.(*ast.MapType); explicitMap {
return false
}
key, ok := pair.Key.(*ast.Ident)
return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value)
}
func potentialHostnameSelectorTarget(expr ast.Expr) bool {
switch n := stripParens(expr).(type) {
case *ast.SelectorExpr:
return isHostnameSemanticName(n.Sel.Name)
case *ast.StarExpr:
return potentialHostnameSelectorTarget(n.X)
case *ast.IndexExpr:
return potentialHostnameSelectorTarget(n.X)
default:
return false
}
}
func (s *fileDomainScan) collectAbsoluteURLEvidence() {
ast.Inspect(s.File, func(node ast.Node) bool {
expr, ok := node.(ast.Expr)
if !ok {
return true
}
if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil {
// A declaration name may carry the constant value in types.Info,
// but it is not a second source expression.
return true
}
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return true
}
if s.hasStaticStringContainer(expr) {
return true
}
host, ok := absoluteURLHostname(value)
if ok {
s.addEvidence(host, "absolute URL", expr)
}
return true
})
}
func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool {
parent, ok := s.parents[expr].(ast.Expr)
if !ok {
return false
}
switch parent.(type) {
case *ast.BinaryExpr, *ast.ParenExpr:
_, ok := staticStringValue(parent, s.Info, nil)
return ok
default:
return false
}
}
func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) {
expr = stripParens(expr)
if composite, ok := expr.(*ast.CompositeLit); ok {
switch s.collectionCompositeKind(composite) {
case sequenceComposite:
for _, element := range composite.Elts {
if valueExpr, ok := element.(ast.Expr); ok {
s.addHostValue(valueExpr, "host collection")
}
}
case mapComposite:
for _, element := range composite.Elts {
pair, ok := element.(*ast.KeyValueExpr)
if !ok {
continue
}
keyExpr, ok := pair.Key.(ast.Expr)
if !ok {
continue
}
s.addMapPair(keyExpr, pair.Value)
}
default:
if s.Info == nil {
s.requireTypeInfoForUnclassifiedCollection(composite)
}
return
}
return
}
if evidence, ok := s.hostnameEvidence(expr, kind); ok {
s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr)
}
}
func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) {
for _, element := range composite.Elts {
if pair, ok := element.(*ast.KeyValueExpr); ok {
keyExpr, ok := pair.Key.(ast.Expr)
if !ok {
continue
}
keyIsHost := s.hasStaticBareHostnameValue(keyExpr)
valueIsHost := s.hasStaticBareHostnameValue(pair.Value)
if keyIsHost == valueIsHost {
continue
}
if keyIsHost {
s.requireTypeInfo(keyExpr)
} else {
s.requireTypeInfo(pair.Value)
}
continue
}
valueExpr, ok := element.(ast.Expr)
if ok && s.hasStaticBareHostnameValue(valueExpr) {
s.requireTypeInfo(valueExpr)
}
}
}
// addMapPair reports a map side only when it is the sole hostname-shaped
// static value. A semantic map name does not establish whether a string map
// is hostname->metadata or alias->hostname, so reporting both sides would turn
// filenames such as client.pem into blocking hostname evidence.
func (s *fileDomainScan) addMapPair(key, value ast.Expr) {
keyEvidence, keyOK := s.hostnameEvidence(key, "host collection")
valueEvidence, valueOK := s.hostnameEvidence(value, "host collection")
if keyOK == valueOK {
return
}
if keyOK {
s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr)
return
}
s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr)
}
func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) {
expr = stripParens(expr)
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return domainEvidence{}, false
}
if host, ok := absoluteURLHostname(value); ok {
return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true
}
if host, ok := semanticHostname(value); ok {
return domainEvidence{Host: host, Kind: kind, Expr: expr}, true
}
return domainEvidence{}, false
}
func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind {
if s.Info != nil {
if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil {
switch tv.Type.Underlying().(type) {
case *types.Array, *types.Slice:
return sequenceComposite
case *types.Map:
return mapComposite
}
}
}
switch expr.Type.(type) {
case *ast.ArrayType:
return sequenceComposite
case *ast.MapType:
return mapComposite
default:
return notCollectionComposite
}
}
func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) {
key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()}
if s.seen[key] {
return
}
s.seen[key] = true
s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr})
}
func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) {
if info != nil {
if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String {
return constant.StringVal(tv.Value), true
}
}
switch n := expr.(type) {
case *ast.BasicLit:
if n.Kind != token.STRING {
return "", false
}
value, err := strconv.Unquote(n.Value)
return value, err == nil
case *ast.ParenExpr:
return staticStringValue(n.X, info, seen)
case *ast.BinaryExpr:
if n.Op != token.ADD {
return "", false
}
left, ok := staticStringValue(n.X, info, seen)
if !ok {
return "", false
}
right, ok := staticStringValue(n.Y, info, seen)
if !ok {
return "", false
}
return left + right, true
case *ast.Ident:
if info != nil {
if obj := info.ObjectOf(n); obj != nil {
if c, ok := obj.(*types.Const); ok {
if c.Val().Kind() == constant.String {
return constant.StringVal(c.Val()), true
}
}
}
}
if n.Obj == nil || n.Obj.Kind != ast.Con {
return "", false
}
if seen == nil {
seen = map[*ast.Object]bool{}
}
if seen[n.Obj] {
return "", false
}
seen[n.Obj] = true
defer delete(seen, n.Obj)
spec, ok := n.Obj.Decl.(*ast.ValueSpec)
if !ok {
return "", false
}
for i, name := range spec.Names {
if name.Name == n.Name && i < len(spec.Values) {
return staticStringValue(spec.Values[i], info, seen)
}
}
}
return "", false
}
func absoluteURLHostname(value string) (string, bool) {
value = strings.TrimSpace(value)
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
return "", false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https", "ws", "wss":
default:
return "", false
}
return normalizeCandidateHostname(parsed.Hostname())
}
func semanticHostname(value string) (string, bool) {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") {
return "", false
}
parsed, err := url.Parse("//" + value)
if err != nil || parsed.Host == "" || parsed.Path != "" {
return "", false
}
return normalizeCandidateHostname(parsed.Hostname())
}
func normalizeCandidateHostname(host string) (string, bool) {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil {
return "", false
}
labels := strings.Split(host, ".")
for _, label := range labels {
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return "", false
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return "", false
}
}
return host, true
}
func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool {
switch n := stripParens(expr).(type) {
case *ast.Ident:
return isHostnameSemanticName(n.Name)
case *ast.SelectorExpr:
return s.isHostnameSelector(n)
case *ast.StarExpr:
return s.isHostnameTarget(n.X)
default:
return false
}
}
func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool {
composite, ok := s.parents[pair].(*ast.CompositeLit)
if !ok {
return false
}
switch s.collectionCompositeKind(composite) {
case mapComposite:
key, ok := pair.Key.(ast.Expr)
return ok && s.isHostnameMapKey(key)
case notCollectionComposite:
ident, ok := pair.Key.(*ast.Ident)
return ok && s.isHostnameStructField(composite, ident.Name)
default:
return false
}
}
func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool {
value, ok := staticStringValue(expr, s.Info, nil)
return ok && isHostnameSemanticName(value)
}
func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool {
if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) {
return false
}
selection := s.Info.Selections[selector]
if selection == nil || selection.Kind() != types.FieldVal {
return false
}
return !nonNetworkHostnameFields[hostnameFieldID{
Type: namedTypeID(selection.Recv()),
Field: selector.Sel.Name,
}]
}
func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool {
if s.Info == nil || !isHostnameSemanticName(field) {
return false
}
typeID := namedTypeID(s.Info.TypeOf(composite))
if typeID == "" {
return false
}
return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}]
}
func namedTypeID(typ types.Type) string {
for {
switch t := typ.(type) {
case *types.Pointer:
typ = t.Elem()
case *types.Named:
obj := t.Obj()
if obj == nil || obj.Pkg() == nil {
return ""
}
return obj.Pkg().Path() + "." + obj.Name()
default:
return ""
}
}
}
func isHostnameSemanticName(name string) bool {
lower := strings.ToLower(name)
switch lower {
case "host", "hosts", "hostname", "hostnames", "domain", "domains":
return true
}
for _, marker := range []string{
"HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy",
} {
if i := strings.Index(name, marker); i >= 0 {
end := i + len(marker)
if end < len(name) && unicode.IsUpper(rune(name[end])) {
return true
}
}
}
for _, prefix := range []string{
"hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy",
} {
if strings.HasPrefix(name, prefix) &&
len(name) > len(prefix) &&
unicode.IsUpper(rune(name[len(prefix)])) {
return true
}
}
if i := strings.LastIndexAny(name, "_-"); i >= 0 {
return isHostnameSemanticName(name[i+1:])
}
for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} {
if strings.HasSuffix(name, suffix) && len(name) > len(suffix) {
return true
}
}
return false
}
func stripParens(expr ast.Expr) ast.Expr {
for {
paren, ok := expr.(*ast.ParenExpr)
if !ok {
return expr
}
expr = paren.X
}
}
func astParentMap(root ast.Node) map[ast.Node]ast.Node {
parents := map[ast.Node]ast.Node{}
var stack []ast.Node
ast.Inspect(root, func(node ast.Node) bool {
if node == nil {
stack = stack[:len(stack)-1]
return false
}
if len(stack) > 0 {
parents[node] = stack[len(stack)-1]
}
stack = append(stack, node)
return true
})
return parents
}
func isDomainFixturePath(rel string) bool {
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "skills/") {
return false
}
if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") {
return true
}
for _, part := range strings.Split(rel, "/") {
if part == "testdata" {
return true
}
}
return false
}
func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation {
return lintapi.Violation{
Rule: unusedDomainRule,
Action: lintapi.ActionReject,
File: entry.File,
Line: entry.Line,
Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host),
Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference",
}
}
func incompleteDomainViolation(file string, err error) lintapi.Violation {
return incompleteDomainViolationAt(file, 1, err)
}
func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation {
return lintapi.Violation{
Rule: incompleteDomainRule,
Action: lintapi.ActionReject,
File: file,
Line: line,
Message: "domain scan incomplete: " + err.Error(),
Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete",
}
}

View File

@@ -0,0 +1,462 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/lint/lintapi"
)
func gitTestCommand(t *testing.T, root string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func setupDomainDiffRepo(t *testing.T, target string) (root, base string) {
t.Helper()
root = t.TempDir()
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n")
writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n")
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n")
writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n")
writeFile(t, root, "target.go", target)
gitTestCommand(t, root, "init", "-q")
gitTestCommand(t, root, "config", "user.name", "Domain Contract Test")
gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com")
gitTestCommand(t, root, "add", ".")
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base")
return root, gitTestCommand(t, root, "rev-parse", "HEAD")
}
func commitDomainDiff(t *testing.T, root, message string) {
t.Helper()
gitTestCommand(t, root, "add", "-A")
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message)
}
func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation {
var out []lintapi.Violation
for _, v := range vs {
if v.Rule == rule {
out = append(out, v)
}
}
return out
}
func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation {
t.Helper()
vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base})
if err != nil {
t.Fatal(err)
}
return vs
}
func TestUnapprovedDomainDiffContract(t *testing.T) {
t.Run("new PR 1975 case", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n")
commitDomainDiff(t, root, "add internal host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") {
t.Fatalf("violations = %+v, want PR 1975 hostname", got)
}
})
t.Run("hostname field in nested Go module", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n")
writeFile(t, root, "nested/target.go",
"package nested\n\ntype Config struct{ Host string }\n\n"+
"var config = Config{Host: \"private.corp.internal\"}\n")
commitDomainDiff(t, root, "add nested module hostname")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, unapprovedDomainRule)
if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" ||
!strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want nested-module hostname rejection", got)
}
if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 {
t.Fatalf("nested module must have complete type information: %+v", incomplete)
}
})
t.Run("changed excluded field reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"var config = Config{Host: \"private.corp.internal\"}\n")
commitDomainDiff(t, root, "add excluded hostname field")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"func configure(config *Config) { config.Host = \"private.corp.internal\" }\n")
commitDomainDiff(t, root, "add excluded hostname selector")
got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got)
}
})
t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type HostList []string\n\n"+
"var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n")
commitDomainDiff(t, root, "add excluded hostname slice")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type HostSet map[string]struct{}\n\n"+
"var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n")
commitDomainDiff(t, root, "add excluded hostname map")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n")
commitDomainDiff(t, root, "add excluded unrelated code")
if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 {
t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got)
}
})
t.Run("new element in existing collection", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n")
writeFile(t, root, "target.go",
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n")
commitDomainDiff(t, root, "add collection host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
t.Fatalf("violations = %+v, want attacker.zip", got)
}
if got[0].Line != 5 {
t.Fatalf("violation line = %d, want 5", got[0].Line)
}
})
t.Run("multiline expression changed segment", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n")
commitDomainDiff(t, root, "change concatenated host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want private.corp.internal", got)
}
if got[0].Line != 4 {
t.Fatalf("violation line = %d, want changed line 4", got[0].Line)
}
})
t.Run("unrelated change beside historical hostname", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n")
commitDomainDiff(t, root, "add unrelated value")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected historical-domain violation: %+v", got)
}
})
t.Run("historical hostname expression changed", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n")
commitDomainDiff(t, root, "change historical host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") {
t.Fatalf("violations = %+v, want replacement.private.internal", got)
}
})
t.Run("new assignment references existing constant", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nconst existingConst = \"private.corp.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n")
commitDomainDiff(t, root, "use existing hostname constant")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want private.corp.internal", got)
}
if got[0].Line != 4 {
t.Fatalf("violation line = %d, want 4", got[0].Line)
}
})
t.Run("allowlisted hostname", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n")
commitDomainDiff(t, root, "add public host")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected public-domain violation: %+v", got)
}
})
t.Run("reserved example URL", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n")
commitDomainDiff(t, root, "add safe example URL")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected reserved-example violation: %+v", got)
}
})
t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) {
root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\nplatform.example.com\npublic.example.com\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"var config = Config{Host: \"platform.example.com\"}\n")
commitDomainDiff(t, root, "add historical platform hostname")
base := gitTestCommand(t, root, "rev-parse", "HEAD")
writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n")
commitDomainDiff(t, root, "change unrelated code")
all := scanDomainDiff(t, root, base)
if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 {
t.Fatalf("historical type gap must not be attributed to this change: %+v", got)
}
if got := violationsForRule(all, unusedDomainRule); len(got) != 0 {
t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got)
}
})
t.Run("allowlist does not approve subdomains", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n")
commitDomainDiff(t, root, "add unapproved public subdomain")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") {
t.Fatalf("violations = %+v, want evil.public.example.com", got)
}
})
t.Run("multi assignment pairs names and values", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\nopen.larksuite.com\npublic.example.com\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n")
commitDomainDiff(t, root, "add multiple hosts")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
t.Fatalf("violations = %+v, want only attacker.zip", got)
}
})
t.Run("IDN hostname is rejected", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n")
commitDomainDiff(t, root, "add IDN hostname")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") {
t.Fatalf("violations = %+v, want IDN hostname", got)
}
})
t.Run("fixture limited to test files", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in production")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
t.Fatalf("violations = %+v, want production fixture rejection", got)
}
if !strings.Contains(got[0].Suggestion, "fixture-only hostname") ||
strings.Contains(got[0].Suggestion, "public allowlist") {
t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion)
}
})
t.Run("fixture accepted in test file", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "new_target_test.go",
"package sample\n\nvar BackupHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in test")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected fixture-domain violation: %+v", got)
}
})
t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "new_target_test.go",
"package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n")
commitDomainDiff(t, root, "use unapproved fixture subdomain")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") {
t.Fatalf("violations = %+v, want exact fixture match", got)
}
})
t.Run("fixture rejected in skills", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "skills/example/example_test.go",
"package example\n\nvar BackupHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in skill")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
t.Fatalf("violations = %+v, want skill fixture rejection", got)
}
})
t.Run("pure rename", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
gitTestCommand(t, root, "mv", "target.go", "renamed.go")
commitDomainDiff(t, root, "rename file")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected rename violation: %+v", got)
}
})
}
func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) {
t.Run("unused policy entry", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\npublic.example.com\nunused.example.com\n")
commitDomainDiff(t, root, "add unused policy")
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") {
t.Fatalf("violations = %+v, want unused.example.com", got)
}
})
t.Run("public entry used only by fixture", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\npublic.example.com\ntest-only.example.com\n")
writeFile(t, root, "public_only_test.go",
"package sample\n\nvar BackupHost = \"test-only.example.com\"\n")
commitDomainDiff(t, root, "add test-only public policy")
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") {
t.Fatalf("violations = %+v, want test-only.example.com", got)
}
})
t.Run("changed Go parse failure", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n")
commitDomainDiff(t, root, "break source")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "target.go" {
t.Fatalf("violations = %+v, want target.go scan-incomplete", got)
}
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused)
}
})
t.Run("repository type loading failure", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+
"require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n")
writeFile(t, root, "target.go",
"package sample\n\nimport _ \"example.com/missing\"\n\n"+
"type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n")
commitDomainDiff(t, root, "break type loading")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" {
t.Fatalf("violations = %+v, want go.mod scan-incomplete", got)
}
if !strings.Contains(got[0].Message, "load Go type information") {
t.Fatalf("message = %q, want type-loading failure", got[0].Message)
}
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused)
}
})
}

View File

@@ -0,0 +1,380 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"go/types"
"sort"
"testing"
)
func scanDomainEvidence(t *testing.T, source string) []domainEvidence {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
if err != nil {
t.Fatalf("parse fixture: %v\n%s", err, source)
}
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset})
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
sort.Slice(scan.Evidence, func(i, j int) bool {
if scan.Evidence[i].Host != scan.Evidence[j].Host {
return scan.Evidence[i].Host < scan.Evidence[j].Host
}
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
})
return scan.Evidence
}
func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence {
t.Helper()
return scanTypedDomainEvidenceInPackage(t, "fixture", source)
}
func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
if err != nil {
t.Fatalf("parse fixture: %v\n%s", err, source)
}
info := &types.Info{
Types: map[ast.Expr]types.TypeAndValue{},
Defs: map[*ast.Ident]types.Object{},
Uses: map[*ast.Ident]types.Object{},
Selections: map[*ast.SelectorExpr]*types.Selection{},
}
if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil {
t.Fatalf("type-check fixture: %v\n%s", err, source)
}
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info})
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
sort.Slice(scan.Evidence, func(i, j int) bool {
if scan.Evidence[i].Host != scan.Evidence[j].Host {
return scan.Evidence[i].Host < scan.Evidence[j].Host
}
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
})
return scan.Evidence
}
func evidenceHosts(evidence []domainEvidence) []string {
hosts := make([]string, 0, len(evidence))
for _, item := range evidence {
hosts = append(hosts, item.Host)
}
return hosts
}
func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) {
evidence := scanTypedDomainEvidence(t,
"package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n")
if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" {
t.Fatalf("hosts = %v, want [private.corp.internal]", got)
}
}
func TestGoDomainEvidenceTruePositives(t *testing.T) {
tests := []struct {
name string
source string
want []string
}{
{
name: "PR 1975 Feishu assignment",
source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n",
want: []string{"internal-api-drive-stream.feishu.cn"},
},
{
name: "PR 1975 Lark assignment",
source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n",
want: []string{"internal-api-drive-stream.larksuite.com"},
},
{
name: "uppercase snake target",
source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n",
want: []string{"private.corp.internal"},
},
{
name: "typed declaration",
source: "package p\nconst APIHost string = \"attacker.zip\"\n",
want: []string{"attacker.zip"},
},
{
name: "grouped const declaration",
source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n",
want: []string{"attacker.zip"},
},
{
name: "grouped var declaration",
source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n",
want: []string{"attacker.zip"},
},
{
name: "multi assignment",
source: "package p\nfunc f() {\n" +
" APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" +
" _, _ = APIHost, BackupHost\n}\n",
want: []string{"attacker.zip", "public.example.com"},
},
{
name: "map semantic key",
source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n",
want: []string{"private.corp.internal"},
},
{
name: "map semantic key assignment",
source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n",
want: []string{"private.corp.internal"},
},
{
name: "host collection values",
source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n",
want: []string{"attacker.zip", "private.corp.internal"},
},
{
name: "host collection map keys",
source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n",
want: []string{"attacker.zip"},
},
{
name: "host collection bool map keys",
source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n",
want: []string{"api.example.com"},
},
{
name: "host collection map values",
source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n",
want: []string{"api.example.com"},
},
{
name: "host collection map value assignment",
source: "package p\nfunc f() {\n" +
" HostsByRegion := map[string]string{}\n" +
" HostsByRegion[\"sg\"] = \"api.example.com\"\n" +
"}\n",
want: []string{"api.example.com"},
},
{
name: "static concatenation",
source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n",
want: []string{"attacker.zip"},
},
{
name: "multiline assignment",
source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n",
want: []string{"attacker.zip"},
},
{
name: "escaped hostname",
source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "hex escaped hostname",
source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "octal escaped hostname",
source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "raw hostname",
source: "package p\nvar APIHost = `private.corp.internal`\n",
want: []string{"private.corp.internal"},
},
{
name: "same-file constant reference",
source: "package p\nconst existingConst = \"private.corp.internal\"\n" +
"func f() { APIHost := existingConst; _ = APIHost }\n",
want: []string{"private.corp.internal"},
},
{
name: "absolute URL",
source: "package p\nvar message = \"https://private.corp.internal/v1\"\n",
want: []string{"private.corp.internal"},
},
{
name: "websocket URL with port",
source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n",
want: []string{"private.corp.internal"},
},
{
name: "URL userinfo query and fragment",
source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n",
want: []string{"private.corp.internal"},
},
{
name: "IDN hostname",
source: "package p\nvar APIHost = \"例子.公司.cn\"\n",
want: []string{"例子.公司.cn"},
},
{
name: "case port and trailing dot normalization",
source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n",
want: []string{"example.com"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := evidenceHosts(scanDomainEvidence(t, tc.source))
if len(got) != len(tc.want) {
t.Fatalf("hosts = %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("hosts = %v, want %v", got, tc.want)
}
}
})
}
}
func TestGoDomainEvidenceTrueNegatives(t *testing.T) {
source := `package p
import _ "github.com/larksuite/oapi-sdk-go/v3"
var file = "archive.zip"
var event = "card.action.trigger"
var schema = "im.messages.list"
var configFile = "service.prod.json"
var version = "v1.2.3"
var email = "name@example.com"
var lowConfidence = "attacker.zip"
var downloadURL = "archive.zip/file"
var prose = "See https://private.corp.internal/v1 for details"
// https://private.corp.internal/v1
var ghost = "private.corp.internal"
var hostnameParser = "private.corp.internal"
var domainError = "private.corp.internal"
var APIHost = "localhost"
var BackupHost = "127.0.0.1"
var hosts = struct{ File string }{File: "archive.zip"}
var AllowedHosts = map[string]string{"api.example.com": "client.pem"}
func dynamicValue() string { return "private.corp.internal" }
var DynamicHost = dynamicValue()
func setAmbiguousHostMetadata() {
AllowedHosts["api.example.com"] = "client.pem"
}
`
if got := scanDomainEvidence(t, source); len(got) != 0 {
t.Fatalf("unexpected evidence: %+v", got)
}
}
func TestTypedStructFieldHostnameSemantics(t *testing.T) {
t.Run("network fields", func(t *testing.T) {
source := `package source
type Config struct { Host string }
type FeishuSource struct { Domain string }
var config = Config{Host: "api.example.com"}
var source = FeishuSource{Domain: "events.example.com"}
`
got := evidenceHosts(scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/internal/event/source",
source,
))
want := []string{"api.example.com", "events.example.com"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("hosts = %v, want %v", got, want)
}
})
t.Run("command metadata domain", func(t *testing.T) {
source := `package cmdmeta
type Meta struct { Domain string }
var meta = Meta{Domain: "im.messages"}
func update(meta *Meta) { meta.Domain = "docs.pages" }
`
if got := scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/internal/cmdmeta",
source,
); len(got) != 0 {
t.Fatalf("unexpected command metadata evidence: %+v", got)
}
})
t.Run("card action host", func(t *testing.T) {
source := `package im
type CardActionTriggerOutput struct { Host string }
var output = CardActionTriggerOutput{Host: "card.action"}
func update(output *CardActionTriggerOutput) { output.Host = "im.message" }
`
if got := scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/events/im",
source,
); len(got) != 0 {
t.Fatalf("unexpected card host evidence: %+v", got)
}
})
t.Run("unknown field ownership is conservative", func(t *testing.T) {
source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n"
if got := scanDomainEvidence(t, source); len(got) != 0 {
t.Fatalf("unexpected untyped field evidence: %+v", got)
}
})
}
func TestHostnameSemanticNames(t *testing.T) {
for _, name := range []string{
"host", "HOST", "hosts", "hostname", "domains",
"api_host", "API_HOST", "ALLOWED_HOSTS",
"apiHost", "APIHost", "backupHostname",
"HostsByRegion", "APIHostsByRegion", "hostsByRegion",
} {
if !isHostnameSemanticName(name) {
t.Errorf("%q should be hostname-semantic", name)
}
}
for _, name := range []string{
"ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin",
"HostBypass", "APIHostBypass",
} {
if isHostnameSemanticName(name) {
t.Errorf("%q must not be hostname-semantic", name)
}
}
}
func TestDomainFixturePaths(t *testing.T) {
for _, path := range []string{
"internal/x/x_test.go",
"tests/cli_e2e/x.go",
"internal/x/testdata/sample.go",
} {
if !isDomainFixturePath(path) {
t.Errorf("%q should be fixture scope", path)
}
}
for _, path := range []string{
"internal/x/test_helper.go",
"examples/demo.go",
"skills/example/testdata/sample.go",
"skills/example/example_test.go",
} {
if isDomainFixturePath(path) {
t.Errorf("%q must not be fixture scope", path)
}
}
}

View File

@@ -3,7 +3,7 @@
// Command lintcheck runs repository source-contract guards that golangci-lint
// cannot express directly. It currently covers typed-error contracts and the
// resolver-owned endpoint contract.
// resolver-owned endpoint and approved-domain contracts.
//
// lintcheck lives in its own Go module under lint/ so its build-time
// dependency on golang.org/x/tools/go/packages does not leak into the
@@ -43,8 +43,10 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
@@ -57,7 +59,7 @@ func main() {
"Runs every registered lint domain against repo-root (default: current directory).\n")
flag.PrintDefaults()
}
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks")
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
flag.Parse()

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.79",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.79",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

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

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheClear clears all cache entries for the app in the given environment.
//
// POST /apps/{app_id}/cache/clearbody {env}。清空当前应用指定环境下全部缓存,用于无法定位
// 具体 key 的快速恢复;影响面大,定 high-risk-write框架自动注入 --yes 确认)。
var AppsCacheClear = common.Shortcut{
Service: appsService,
Command: "+cache-clear",
Description: "Clear all cache entries for the app in the given environment",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appCacheClearPath(appID)).
Desc("Clear all cache entries for the app in the given environment").
Body(dbEnvParams(rctx, map[string]interface{}{}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheClearPretty(w, out)
})
return nil
},
}
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
n := int64(0)
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
n = int64(f)
}
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheDelete deletes a single business cache key (idempotent).
//
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
// 故定 write非 high-risk-write、不需 --yes。目标不存在按幂等成功处理deleted_key_count=0
var AppsCacheDelete = common.Shortcut{
Service: appsService,
Command: "+cache-delete",
Description: "Delete a single business cache key (idempotent)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
DELETE(appCachePath(appID)).
Desc("Delete a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheDeletePretty(w, out)
})
return nil
},
}
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
key := common.GetString(out, "key")
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
return
}
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheGet reads a single business cache key's value + metadata.
//
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
// 按 value 字节长度算出端点不返回未命中exists=false时不带 valuettl_ms/value_size_bytes 为 null。
var AppsCacheGet = common.Shortcut{
Service: appsService,
Command: "+cache-get",
Description: "Get a business cache key's value and metadata",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appCachePath(appID)).
Desc("Get a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := projectCacheGet(data, key, rctx)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheGetPretty(w, out)
})
return nil
},
}
// projectCacheGet 组装 cache-get 输出key 回显、environment 取 resolved env、exists 直读;
// 命中时带 ttl_ms + value原始串+ value_size_bytesCLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
exists := cacheBool(data["exists"])
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"exists": exists,
}
if exists {
val := common.GetString(data, "value")
out["ttl_ms"] = cacheInt(data["ttl_ms"])
out["value_size_bytes"] = len([]byte(val))
out["value"] = val
} else {
out["ttl_ms"] = nil
out["value_size_bytes"] = nil
}
return out
}
// renderCacheGetPretty 打元信息块key/environment/exists命中再加 ttl/value_size命中时末尾展开 value。
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
exists, _ := out["exists"].(bool)
pairs := [][2]string{
{"key", common.GetString(out, "key")},
{"environment", common.GetString(out, "environment")},
{"exists", fmt.Sprintf("%v", exists)},
}
if exists {
pairs = append(pairs,
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
)
}
renderKeyValuePairs(w, pairs)
if exists {
fmt.Fprintln(w, "value:")
printCacheValuePretty(w, common.GetString(out, "value"))
}
}

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
const (
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
)
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串value 不反序列化)。
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
// ── cache-get ──
// TestAppsCacheGet_HitJSON命中时 json 默认——value 原样透传(不反序列化),
// value_size_bytes 由 CLI 按 value 字节长度算出environment 取服务端 resolved env。
func TestAppsCacheGet_HitJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
t.Fatalf("get hit data=%v", d)
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
}
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
}
// ttl_ms 必须是 JSON number透传服务端数字不得变成字符串JSON 解析后为 float64。
if _, ok := d["ttl_ms"].(float64); !ok {
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
}
// TestAppsCacheGet_HitPrettypretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
func TestAppsCacheGet_HitPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
}
// TestAppsCacheGet_Miss未命中——exists=false无 valuettl_ms / value_size_bytes 为 null。
func TestAppsCacheGet_Miss(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": false,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != false {
t.Fatalf("miss exists=%v", d["exists"])
}
if _, ok := d["value"]; ok {
t.Fatalf("miss must not carry value: %v", d)
}
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
}
}
// TestAppsCacheGet_ExistsAsString服务端把 exists 返成字符串 "true" 时仍按命中处理
// cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != true {
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("命中应带 value, got %v", d["value"])
}
}
// TestAppsCacheGet_PrettyNonJSONFallbackpretty 下 value 不是合法 JSON 时降级原样输出
// safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
}
}
// TestAppsCacheGet_TTLAsStringNormalized服务端把 ttl_ms 返成字符串 "272000" 时,
// 输出的 ttl_ms 必须归一成 JSON numbercacheInt不得随 wire 形态漂移成字符串。
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
f, ok := d["ttl_ms"].(float64)
if !ok {
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
if int(f) != 272000 {
t.Fatalf("ttl_ms = %v, want 272000", f)
}
}
// TestAppsCacheDelete_CountAsStringNormalized服务端把 deleted_key_count 返成字符串 "1" 时,
// 输出必须归一成 JSON numbercacheInt
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if _, ok := d["deleted_key_count"].(float64); !ok {
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
}
}
// TestAppsCacheGet_DryRunOmitsEnv不传 --environment 时 dry-run query 不带 env服务端自动选但带 key。
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "GET" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
}
if a.Params["key"] != "k:1" {
t.Fatalf("key must be in query, params=%v", a.Params)
}
}
// TestAppsCacheGet_DryRunWithEnv显式 --environment dev → query 带 env=dev。
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Params["env"] != "dev" {
t.Fatalf("env must be dev, params=%v", a.Params)
}
}
// TestAppsCacheGet_RequiresKey缺 --key → 校验错。
func TestAppsCacheGet_RequiresKey(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --key error")
}
}
// ── cache-delete ──
// TestAppsCacheDelete_Hit删中命中的 key → deleted_key_count=1pretty 打 "✓ cache deleted"。
func TestAppsCacheDelete_Hit(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache deleted") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_AbsentJSON目标不存在 → 幂等成功deleted_key_count=0pretty 措辞区分。
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
t.Fatalf("absent data=%v", d)
}
}
// TestAppsCacheDelete_AbsentPretty不存在 pretty 打 "✓ cache already absent"。
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "already absent") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_DryRunDELETE 方法、/cache 路由query 带 key + env。
func TestAppsCacheDelete_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "DELETE" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
t.Fatalf("params=%v", a.Params)
}
}
// ── cache-clear ──
// TestAppsCacheClear_Success清空成功 → deleted_key_count=128pretty 打 "✓ cache cleared: 128 entries (dev)"。
func TestAppsCacheClear_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: cacheClearURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
})
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheClear_RequiresConfirmationhigh-risk-write 无 --yes → 被确认门拦截。
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected confirmation gate without --yes")
}
}
// TestAppsCacheClear_DryRunBodyWithEnvdry-run POST /cache/clearbody 带 env=dev。
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "POST" || a.URL != cacheClearURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["env"] != "dev" {
t.Fatalf("body must carry env=dev, body=%v", a.Body)
}
}
// TestAppsCacheClear_DryRunBodyOmitsEnv不传 --environment → body 不带 env服务端自动选
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if _, ok := a.Body["env"]; ok {
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
}
}
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项method/url/params/body
// 复用本包规范的 dryRunAPIEnvelopeapi 现嵌在 data.api 下,见 dryrun_test.go
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
t.Helper()
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
t.Fatalf("bad dry-run json: %v\n%s", err, s)
}
return env.API[0]
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// 应用运行时缓存Cache调试命令共享件路由 + 环境 flag + 渲染。
//
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`按运行环境env→dbBranch隔离
// 环境 flag 用 cacheEnvFlag()(只 --environment不带 db 家族的旧名 --envenv 值经 dbEnv 读、
// 经 dbEnvParams 注入——get/delete 放 queryclear 放 body省略即服务端自动选分支
// appCachePath 返回缓存单 key 读/删 URLcacheGET 读、DELETE 删,靠方法区分)。
func appCachePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
}
// appCacheClearPath 返回清空指定环境缓存 URLcache/clear。
func appCacheClearPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
}
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env
// 故只注册干净的 --environment不带 db 家族那套隐藏 --env + 拒收逻辑)。
// 省略即服务端按应用多环境状态自动选分支多环境→dev非多环境→online
func cacheEnvFlag() common.Flag {
return common.Flag{
Name: "environment",
Enum: []string{"dev", "online"},
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
}
}
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中hit→miss 翻转)。
func cacheBool(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true")
}
return false
}
// cacheInt 把服务端下发的数值字段归一成 int64无法解析→nil。本仓惯例数值可能以字符串下发
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
// number ↔ string。归一后输出类型恒定为数字或 null消费方无需自己容忍字符串。
func cacheInt(raw interface{}) interface{} {
if f, ok := numericAsFloat(raw); ok {
return int64(f)
}
return nil
}
// resolvedEnv 取服务端回吐的 resolved env缺失时兜底成请求侧 --environment可能为空
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
if env := common.GetString(data, "env"); env != "" {
return env
}
return dbEnv(rctx)
}
// formatCacheTTL 把剩余 TTL毫秒格式化成 4m32s 这样的时长串;非数字返回 "—"。
func formatCacheTTL(ms interface{}) string {
f, ok := numericAsFloat(ms)
if !ok {
return "—"
}
return (time.Duration(int64(f)) * time.Millisecond).String()
}
// printCacheValuePretty 把 value 反序列化后缩进展开pretty 口径);非 JSON 则原样打印。
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
func printCacheValuePretty(w io.Writer, raw string) {
v := safeParseJSON(raw)
if s, ok := v.(string); ok {
fmt.Fprintln(w, s)
return
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintln(w, raw)
return
}
w.Write(b)
fmt.Fprintln(w)
}

View File

@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsCacheGet,
AppsCacheDelete,
AppsCacheClear,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,

View File

@@ -20,13 +20,14 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 cacheget/delete/clear
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79
func TestAppsShortcuts_Returns79(t *testing.T) {
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 82
func TestAppsShortcuts_Returns82(t *testing.T) {
got := Shortcuts()
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
if len(got) != 82 {
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
}
}

View File

@@ -4,6 +4,7 @@
package base
import (
"encoding/json"
"strings"
"testing"
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
"total": 2,
"questions": []interface{}{
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
},
},
},
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
got := stdout.String()
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
t.Fatalf("stdout=%s", got)
}
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
if !strings.Contains(got, `"visible_rule"`) {
t.Fatalf("visible_rule missing from list output: %s", got)
}
}
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
t.Fatalf("expected error for invalid questions JSON")
}
})
t.Run("visible_rule passthrough", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
},
},
},
}
reg.Register(stub)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body struct {
Questions []map[string]interface{} `json:"questions"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
}
if len(body.Questions) != 1 {
t.Fatalf("questions=%#v", body.Questions)
}
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
if !ok {
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
}
if rule["logic"] != "and" {
t.Fatalf("visible_rule logic not preserved: %#v", rule)
}
})
}
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
},
},
},
})
}
reg.Register(stub)
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
t.Fatalf("stdout=%s", got)
}
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
var body struct {
Questions []map[string]interface{} `json:"questions"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
}
if len(body.Questions) != 1 {
t.Fatalf("questions=%#v", body.Questions)
}
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
}
}
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -25,14 +26,25 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
Tips: []string{
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
"Each new question creates a field in the form's table; question IDs are field IDs.",
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
Set("form_id", runtime.Str("form-id")).
Body(map[string]interface{}{"questions": questions})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
@@ -40,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
formId := runtime.Str("form-id")
questionsJSON := runtime.Str("questions")
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
questions, err := parseFormQuestionsCreate(questionsJSON)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST",
@@ -71,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
return nil
},
}
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
var questions []interface{}
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
if questions == nil {
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
}
if len(questions) > 10 {
return nil, baseValidationErrorf("--questions must contain at most 10 items")
}
for i, question := range questions {
item, ok := question.(map[string]interface{})
if !ok {
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
}
title, ok := item["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
}
questionType, ok := item["type"].(string)
if !ok || strings.TrimSpace(questionType) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
}
}
return questions, nil
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
)
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
for _, want := range []string{
"+form-questions-list",
"verified empty form can create directly",
"question IDs are field IDs",
"explicitly requests a separate same-title question",
"+form-questions-update",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}

View File

@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
},
Tips: []string{
"Update uses full question overwrite semantics, not a patch.",
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
api := common.NewDryRunAPI().
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")

View File

@@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{
Risk: "read",
Scopes: []string{},
ConditionalScopes: []string{
"base:block:read",
"base:field:read",
"base:record:read",
"wiki:node:retrieve",
@@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{
return common.NewDryRunAPI().Set("error", err.Error())
}
switch classifyBaseURL(parsed) {
case "base_url":
baseToken := firstPathSegmentAfter(parsed.Path, "/base/")
if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Body(map[string]interface{}{}).
Set("base_token", baseToken).
Set("selected_block_id", selectedBlockID)
}
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
case "wiki_url":
return common.NewDryRunAPI().
GET("/open-apis/wiki/v2/spaces/get_node").
dry := common.NewDryRunAPI()
selectedBlockID := strings.TrimSpace(parsed.Query().Get("table"))
if selectedBlockID == "" {
return dry.
GET("/open-apis/wiki/v2/spaces/get_node").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
}
dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block")
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve the Wiki node to its underlying Base").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Desc("[2] List Base blocks and match selected_block_id").
Body(map[string]interface{}{})
return dry.
Set("base_token", "<obj_token from step 1>").
Set("selected_block_id", selectedBlockID)
case "record_share_url":
return common.NewDryRunAPI().
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
@@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
switch classifyBaseURL(parsed) {
case "base_url":
out := resolveBaseURL(parsed)
enrichBaseResolveHint(runtime, out)
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
runtime.OutFormat(out, nil, nil)
return nil
case "wiki_url":
@@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
selection := resolveBaseURLSelection(parsed)
applyBaseURLSelection(out, selection)
enrichBaseResolveHint(runtime, out, selection)
runtime.OutFormat(out, nil, nil)
return nil
case "record_share_url":
@@ -251,24 +279,50 @@ func classifyBaseURL(u *url.URL) string {
}
func resolveBaseURL(u *url.URL) map[string]interface{} {
query := u.Query()
out := map[string]interface{}{
"input_type": "base_url",
"resource_type": "bitable",
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
}
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
out["table_id"] = tableID
}
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
out["view_id"] = viewID
}
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
out["record_id"] = recordID
}
applyBaseURLSelection(out, resolveBaseURLSelection(u))
return out
}
type baseURLSelection struct {
blockID string
viewID string
recordID string
}
func resolveBaseURLSelection(u *url.URL) baseURLSelection {
query := u.Query()
return baseURLSelection{
blockID: strings.TrimSpace(query.Get("table")),
viewID: strings.TrimSpace(query.Get("view")),
recordID: strings.TrimSpace(query.Get("record")),
}
}
func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) {
if selection.blockID != "" {
// The Base web UI historically uses the query key "table" for the
// currently selected top-level block. Its value can identify a table,
// dashboard, workflow, or another block type. Keep it neutral until the
// block directory confirms the resource type.
out["block_id"] = selection.blockID
out["selection_source"] = "url_query"
}
}
func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) {
if selection.viewID != "" {
out["view_id"] = selection.viewID
}
if selection.recordID != "" {
out["record_id"] = selection.recordID
}
}
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
@@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
}
}
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
if baseToken == "" || tableID == "" {
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
if baseToken == "" || selectedBlockID == "" {
out["hint"] = resolveHint("", nil)
return
}
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
out["block_type"] = block.Type
if block.Name != "" {
out["block_name"] = block.Name
}
switch block.Type {
case "table":
applyResolvedTableSelection(out, selection)
enrichResolvedTable(runtime, out, baseToken, selectedBlockID)
case "dashboard":
out["dashboard_id"] = selectedBlockID
out["hint"] = map[string]interface{}{
"next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard",
}
case "workflow":
out["workflow_id"] = selectedBlockID
out["hint"] = map[string]interface{}{
"next_step": "use +workflow-get to inspect the resolved workflow",
}
case "folder":
out["hint"] = map[string]interface{}{
"next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID),
}
case "docx":
if block.DocxToken != "" {
out["docx_token"] = block.DocxToken
out["hint"] = map[string]interface{}{
"next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken),
}
} else {
out["hint"] = map[string]interface{}{
"next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token",
}
}
default:
out["hint"] = resolveUnknownBlockHint()
}
return
}
out["hint"] = resolveUnknownBlockHint()
}
type resolvedBaseBlock struct {
ID string
Type string
Name string
DocxToken string
}
func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{})
if err != nil {
return resolvedBaseBlock{}, false, err
}
for _, item := range common.GetSlice(data, "blocks") {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
block := resolvedBaseBlock{
ID: strings.TrimSpace(common.GetString(row, "id")),
Type: strings.TrimSpace(common.GetString(row, "type")),
Name: strings.TrimSpace(common.GetString(row, "name")),
DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")),
}
if block.ID == selectedBlockID {
return block, true, nil
}
}
return resolvedBaseBlock{}, false, nil
}
func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) {
out["table_id"] = tableID
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
if err != nil {
out["hint"] = resolveHint(tableID, nil)
@@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
}
func resolveUnknownBlockHint() map[string]interface{} {
return map[string]interface{}{
"next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block",
}
}
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))

View File

@@ -4,6 +4,7 @@
package base
import (
"net/http"
"strings"
"testing"
@@ -17,6 +18,9 @@ import (
func TestBaseURLResolveBaseURL(t *testing.T) {
t.Run("with coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
@@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("missing Base coordinates: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
@@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
}
})
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
if data["base_token"] != "bas123" || data["block_id"] != "tbl123" {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("unconfirmed block must not be reported as a table: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList {
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
))
fieldStub := fieldListStub("bas123", "tbl123")
fieldStub.Optional = true
fieldStub.OnMatch = func(_ *http.Request) {
t.Fatalf("field endpoint must not be used to infer selected block type")
}
reg.Register(fieldStub)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "tbl123" {
t.Fatalf("unexpected block coordinates: %#v", data)
}
if _, ok := data["block_type"]; ok {
t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("untyped block must not expose table-only view_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint)
}
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("dashboard selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" {
t.Fatalf("unexpected dashboard coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("dashboard must not be reported as table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") {
t.Fatalf("unexpected dashboard hint: %#v", hint)
}
})
t.Run("workflow selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" {
t.Fatalf("unexpected workflow coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("workflow must not be reported as table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("workflow must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("workflow must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if !strings.Contains(hint["next_step"].(string), "+workflow-get") {
t.Fatalf("unexpected workflow hint: %#v", hint)
}
})
t.Run("folder selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" {
t.Fatalf("unexpected folder coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("folder must not be reported as table_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") {
t.Fatalf("unexpected folder hint: %#v", hint)
}
})
t.Run("docx selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" {
t.Fatalf("unexpected docx coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("docx must not be reported as table_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") {
t.Fatalf("unexpected docx hint: %#v", hint)
}
})
}
func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub {
items := make([]interface{}, 0, len(blocks))
for _, block := range blocks {
items = append(items, block)
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": items,
"total": len(items),
},
},
}
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bas123",
"title": "Demo Base",
},
},
},
})
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
@@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
}
})
t.Run("bitable with table coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected Wiki Base table coordinates: %#v", data)
}
})
t.Run("bitable with dashboard selection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" {
t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
}
})
t.Run("non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
})
}
func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": baseToken,
"title": title,
},
},
},
}
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)

View File

@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "form question create visible_rule",
shortcut: BaseFormQuestionsCreate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
name: "form question update visible_rule",
shortcut: BaseFormQuestionsUpdate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
name: "record search json",
shortcut: BaseRecordSearch,
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"Update uses full question overwrite semantics",
"run +form-questions-list first",
"include existing values you want to keep",
"pass null or omit to clear",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
"full question overwrite semantics, not a patch",
"Run +form-questions-list first",
"title/description/required/option_display_mode/visible_rule",
"Omitted fields reset to defaults",
"empty strings, null, and empty arrays are written as empty/clear",
}
for _, want := range wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string

View File

@@ -98,6 +98,26 @@ func TestCallAPITyped_Success(t *testing.T) {
}
}
func TestCallAPITyped_ExtraHeaderFromEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_NAME", "x-tt-env")
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_VALUE", "boe_whiteboard_test")
rt, reg := newCallAPITypedRuntime(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/wb/nodes/batch_update",
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"ids": []interface{}{"a1:1"}}},
}
reg.Register(stub)
_, err := rt.CallAPITyped("PUT", "/open-apis/board/v1/whiteboards/wb/nodes/batch_update", nil, map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stub.CapturedHeaders.Get("x-tt-env"); got != "boe_whiteboard_test" {
t.Fatalf("x-tt-env header = %q, want boe_whiteboard_test", got)
}
}
// TestAPIClassifyContext verifies the classify context is built from the
// runtime: Brand / AppID from config, Identity from the resolved caller, and
// LarkCmd from the running command path.

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const botSearchURL = "/open-apis/bot/v4/bot/search"
const (
maxBotSearchQueryChars = 50
maxBotSearchChatIDs = 100
maxBotSearchPageSize = 30
)
type botSearchAPIRequest struct {
Query string `json:"query,omitempty"`
Filter *botSearchAPIFilter `json:"filter,omitempty"`
}
// HasChatter uses omitempty: validation rejects =false, so a set field is always
// true and an unset field stays out of the request entirely.
type botSearchAPIFilter struct {
ChatIDs []string `json:"chat_ids,omitempty"`
HasChatter bool `json:"has_chatter,omitempty"`
}
type botSearchAPIData struct {
Items []botSearchAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type botSearchAPIItem struct {
ID string `json:"id"`
DisplayInfo string `json:"display_info"`
MetaData botSearchAPIMeta `json:"meta_data"`
}
type botSearchAPIMeta struct {
TenantID string `json:"tenant_id"`
EnableJoinGroup bool `json:"enable_join_group"`
ChatID string `json:"chat_id"`
IsAgent bool `json:"is_agent"`
}
type searchBot struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// ChatID is the caller's P2P chat with the bot.
ChatID string `json:"chat_id"`
EnableJoinGroup bool `json:"enable_join_group"`
IsAgent bool `json:"is_agent"`
TenantID string `json:"tenant_id,omitempty"`
MatchSegments []string `json:"match_segments"`
}
// PageToken is decoded from the response but deliberately not surfaced, matching
// searchUserResponse: neither search command paginates. Callers narrow the query
// instead, so handing out a token that no flag accepts would only mislead.
type searchBotResponse struct {
Bots []searchBot `json:"bots"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchBot = common.Shortcut{
Service: "contact",
Command: "+search-bot",
Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)",
Risk: "read",
Scopes: []string{"search:bot"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"},
{Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"},
{Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotSearch(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range parseAndDedupQueries(raw) {
body := &botSearchAPIRequest{Query: q, Filter: filter}
api.POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildBotSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
},
Execute: executeBotSearch,
}
// executeBotSearch dispatches to single-query or fanout mode.
func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeBotSearchFanout(ctx, runtime)
}
return executeBotSearchSingle(ctx, runtime)
}
// botSearchKeywordRequiredError names every flag that can satisfy the keyword
// requirement. Naming only --query would tell an agent that --queries is not a
// way out, which it is.
func botSearchKeywordRequiredError() error {
return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"},
errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"},
)
}
// botSearchHasChattedFalseError is raised from two places — with and without a
// keyword — so the wording stays in one spot.
//
// Agents passing =false almost always mean "do not filter", but the API reads it
// as "must NOT match". A hard error prevents silent wrong results.
func botSearchHasChattedFalseError() error {
return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)").
WithParam("--has-chatted")
}
func validateBotSearch(runtime *common.RuntimeContext) error {
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
query := strings.TrimSpace(runtime.Str("query"))
explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted")
if queriesRaw != "" {
if query != "" {
return common.ValidationErrorf("--query and --queries are mutually exclusive").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
)
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
WithParam("--queries")
}
if len(queries) > maxFanoutQueries {
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
WithParam("--queries")
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxBotSearchQueryChars {
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars).
WithParam("--queries")
}
}
} else if query == "" {
// No keyword at all. An explicit =false is the more specific mistake, so
// report it instead of sending the caller off to add a keyword only to hit
// this on the next attempt. +search-user lands here too: a Changed bool
// counts as search input for its "at least one" gate, so the =false check
// is what it reaches next.
//
// Scoped to the no-keyword case on purpose. Hoisting it above the keyword
// checks would let it mask the mutual-exclusion and length errors, which
// +search-user reports first when a keyword is present.
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
return botSearchKeywordRequiredError()
} else if utf8.RuneCountInString(query) > maxBotSearchQueryChars {
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars).
WithParam("--query")
}
if _, err := parseBotSearchChatIDs(runtime); err != nil {
return err
}
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize {
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize).
WithParam("--page-size")
}
return nil
}
func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
raw := strings.TrimSpace(runtime.Str("chat-ids"))
if raw == "" {
return nil, nil
}
parts := common.SplitCSV(raw)
if len(parts) == 0 {
return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw).
WithParam("--chat-ids")
}
// Normalize before deduping, then check the cap against the deduped list —
// the same order common.resolveOpenIDs uses for --user-ids. Doing it the other
// way would spend the server's 100-entry budget on duplicates, and would let
// 101 copies of one chat be rejected here while the sibling command accepts
// them. Normalization matters too: a chat URL and a bare chat_id can name the
// same chat.
seen := make(map[string]struct{}, len(parts))
chatIDs := make([]string, 0, len(parts))
for _, part := range parts {
normalized, err := common.ValidateChatIDTyped("--chat-ids", part)
if err != nil {
return nil, err
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
chatIDs = append(chatIDs, normalized)
}
if len(chatIDs) > maxBotSearchChatIDs {
return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs).
WithParam("--chat-ids")
}
return chatIDs, nil
}
// buildBotSearchFilter reads the scope flags shared by single and fanout search.
// A nil filter means "no scope": an empty filter object is not the same request.
func buildBotSearchFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) {
filter := &botSearchAPIFilter{}
hasFilter := false
chatIDs, err := parseBotSearchChatIDs(runtime)
if err != nil {
return nil, err
}
if len(chatIDs) > 0 {
filter.ChatIDs = chatIDs
hasFilter = true
}
if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") {
filter.HasChatter = true
hasFilter = true
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return nil, err
}
return &botSearchAPIRequest{
Query: strings.TrimSpace(runtime.Str("query")),
Filter: filter,
}, nil
}
// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the
// response envelope — notice, has_more, and in fanout mode queries[] — into
// stdout. Only json does; pretty, table, csv and ndjson render rows only, so
// every piece of "this result is not the whole answer" metadata would vanish and
// the caller would read a truncated result as a complete one. For those formats
// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression
// can still project it away, but that is the caller's explicit choice.
func botSearchStdoutCarriesEnvelope(format string) bool {
return format == "json" || format == ""
}
func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildBotSearchBody(runtime)
if err != nil {
return err
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return err
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return err
}
bots := projectBots(respData)
out := searchBotResponse{
Bots: bots,
HasMore: respData.HasMore,
Notice: respData.Notice,
}
runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) {
if len(bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotRows(bots))
})
if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
}
if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintln(runtime.IO().ErrOut,
"\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
}
return nil
}
func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err)
}
var out botSearchAPIData
if err := json.Unmarshal(raw, &out); err != nil {
return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err)
}
return &out, nil
}
func projectBots(data *botSearchAPIData) []searchBot {
if data == nil {
return []searchBot{}
}
bots := make([]searchBot, 0, len(data.Items))
for i := range data.Items {
item := &data.Items[i]
name, description, segments := parseBotDisplayInfo(item.DisplayInfo)
bots = append(bots, searchBot{
OpenID: item.ID,
Name: name,
Description: description,
ChatID: item.MetaData.ChatID,
EnableJoinGroup: item.MetaData.EnableJoinGroup,
IsAgent: item.MetaData.IsAgent,
TenantID: item.MetaData.TenantID,
MatchSegments: segments,
})
}
return bots
}
func stripHighlightTags(value string) string {
value = strings.ReplaceAll(value, "<h>", "")
return strings.ReplaceAll(value, "</h>", "")
}
func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
matchSegments = make([]string, 0)
for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) {
// The capture can still carry a tag: the non-greedy pattern pairs a
// stray `<h>` with the next `</h>`. Strip it so a segment reads like the
// name and description it came from, and drop a highlight with no text.
segment := html.UnescapeString(stripHighlightTags(match[1]))
if strings.TrimSpace(segment) == "" {
continue
}
matchSegments = append(matchSegments, segment)
}
lines := strings.Split(raw, "\n")
stripTags := func(value string) string {
return strings.TrimSpace(html.UnescapeString(stripHighlightTags(value)))
}
// nameLine records which line the name came from, so the description is read
// from the line after it. Reading lines[1] unconditionally echoes the name
// back as its own description whenever line 0 is blank, and drops the real
// description with it.
nameLine := -1
if len(lines) > 0 {
if candidate := stripTags(lines[0]); candidate != "" {
name = candidate
nameLine = 0
}
}
if name == "" {
for i, line := range lines {
if candidate := stripTags(line); candidate != "" {
name = candidate
nameLine = i
break
}
}
}
if nameLine >= 0 && nameLine+1 < len(lines) {
description = stripTags(lines[nameLine+1])
}
return name, description, matchSegments
}
// map[] shape is required by output.PrintTable.
func prettyBotRows(bots []searchBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,289 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// Bot fanout reuses the user fanout's query parsing, concurrency limit and
// response summary types.
type botFanoutResult struct {
Index int
Query string
Bots []searchBot
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed propagation
}
// runOneBotQuery converts one fanout request into either bots or an error summary.
func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *botSearchAPIFilter) botFanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a request;
// in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return botFanoutErrorResult(index, query, err)
}
body := &botSearchAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return botFanoutErrorResult(index, query, err)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
return botFanoutResult{
Index: index,
Query: query,
Bots: projectBots(respData),
HasMore: respData.HasMore,
Notice: respData.Notice,
}
}
// botFanoutErrorResult records a failed fanout query without stopping other workers.
func botFanoutErrorResult(index int, query string, err error) botFanoutResult {
if err == nil {
return botFanoutResult{Index: index, Query: query}
}
return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
}
func botFanoutContextError(err error) error {
subtype := errs.SubtypeNetworkTransport
message := "bot search fanout cancelled"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
message = "bot search fanout deadline exceeded"
}
return errs.NewNetworkError(subtype, "%s", message).WithCause(err)
}
func botFanoutPanicError(query string, recovered any) error {
err := errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q panicked: %v", query, recovered)
if cause, ok := recovered.(error); ok {
return err.WithCause(cause)
}
return err
}
// Terminal failures invalidate the batch; API and network failures remain
// eligible for partial-success reporting.
func botFanoutTerminalError(results []botFanoutResult) error {
for _, result := range results {
if result.Err == nil {
continue
}
if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) {
return botFanoutContextError(result.Err)
}
problem, ok := errs.ProblemOf(result.Err)
if !ok {
return errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q failed with an unclassified error: %v", result.Query, result.Err).
WithCause(result.Err)
}
if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork {
return result.Err
}
}
return nil
}
type fanoutBot struct {
searchBot
MatchedQuery string `json:"matched_query"`
}
type botFanoutResponse struct {
Bots []fanoutBot `json:"bots"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildBotFanoutResponse flattens recoverable results in query order. Terminal
// errors fail the batch even when another query succeeded.
func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) {
if err := botFanoutTerminalError(results); err != nil {
return nil, err
}
indexed := make([]botFanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &botFanoutResponse{
Bots: make([]fanoutBot, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErr error
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErr = r.Err
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, b := range r.Bots {
out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
return nil, contactFanoutAllFailedError(firstErr, msg)
}
return out, nil
}
func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return err
}
results := make([]botFanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
schedule:
for i, q := range queries {
select {
case sem <- struct{}{}:
case <-ctx.Done():
for j := i; j < len(queries); j++ {
results[j] = botFanoutErrorResult(j, queries[j], ctx.Err())
}
break schedule
}
wg.Add(1)
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
err := botFanoutPanicError(q, r)
results[i] = botFanoutResult{
Index: i,
Query: q,
ErrMsg: contactFanoutErrorSummary(err),
Err: err,
}
}
}()
results[i] = runOneBotQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildBotFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) {
if len(resp.Bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotFanoutRows(resp.Bots))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n",
len(queries), len(resp.Bots), failed, hasMoreCount)
}
// The counts above say how many queries failed but not which, and only the
// json envelope carries queries[].error / queries[].notice. Without this an
// agent reading csv or a table sees "1 failed" with no way to learn the
// keyword or the reason, and a notice disappears entirely.
if !botSearchStdoutCarriesEnvelope(runtime.Format) {
for _, qs := range resp.Queries {
if qs.Error != "" {
fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error)
}
if qs.Notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice)
}
if qs.HasMore {
fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
}
}
}
return nil
}
func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"matched_query": bot.MatchedQuery,
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,684 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) {
r := botFanoutErrorResult(3, "会议助手", nil)
if r.ErrMsg != "" || r.Err != nil {
t.Fatalf("nil error must stay a success result: %+v", r)
}
if r.Index != 3 || r.Query != "会议助手" {
t.Fatalf("index/query must survive: %+v", r)
}
}
func TestBotFanoutAssembleOrderAndShape(t *testing.T) {
results := []botFanoutResult{
{Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}},
{Index: 2, Query: "审批", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Results are emitted in query order even though the workers finished out of
// order, and a failed query contributes no rows.
wantRows := []struct {
openID, matched string
}{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}}
if len(resp.Bots) != len(wantRows) {
t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows))
}
for i, w := range wantRows {
if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched {
t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched)
}
}
want := []querySummary{
{Query: "会议"},
{Query: "日报", HasMore: true},
{Query: "审批", Error: "API 1: nope"},
}
if len(resp.Queries) != len(want) {
t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want))
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)},
{Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("expected an error when every query fails")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's classification must survive, so the caller can tell a
// rate limit apart from a transport fault.
if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit {
t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit)
}
// Agents grep the count and the first failure out of this message.
for _, want := range []string{"all 2 queries failed", "rate limit"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
{Index: 1, Query: "日报", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err != nil {
t.Fatalf("one failure out of two must not fail the call: %v", err)
}
if len(resp.Bots) != 1 || resp.Queries[1].Error == "" {
t.Fatalf("partial failure shape: %+v", resp)
}
}
func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) {
tests := []struct {
name string
err error
wantSubtype errs.Subtype
}{
{name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport},
{name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
botFanoutErrorResult(1, "日报", tt.err),
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("terminal context error must fail the batch after a partial success")
}
if !errors.Is(err, tt.err) {
t.Fatalf("error must preserve %v as its cause: %v", tt.err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype {
t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype)
}
})
}
}
func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// has_more is per query in the sidecar; a single top-level flag would hide
// which keyword was truncated.
if _, ok := envelope["has_more"]; ok {
t.Fatalf("fanout must not surface a top-level has_more: %s", raw)
}
if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) {
t.Fatalf("per-query has_more lost: %s", raw)
}
}
func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"bots":[]`) {
t.Fatalf("empty bots must serialize as [], not null: %s", raw)
}
}
func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) {
rows := prettyBotFanoutRows([]fanoutBot{{
searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)},
MatchedQuery: "会议",
}})
if len(rows) != 1 {
t.Fatalf("rows: %d", len(rows))
}
if rows[0]["matched_query"] != "会议" {
t.Errorf("matched_query missing: %+v", rows[0])
}
if got := rows[0]["description"].(string); len([]rune(got)) > 51 {
t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got)))
}
}
func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", "会议")
setBotSearchFlag(t, cmd, "queries", "会议,日报")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if err == nil {
t.Fatal("expected mutual-exclusion error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("message: %v", err)
}
}
func TestBotFanoutValidationLimits(t *testing.T) {
tests := []struct {
name string
queries string
wantParam string
}{
{name: "nothing parses", queries: " , , ", wantParam: "--queries"},
{name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"},
{name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
queries := tt.queries
if strings.Contains(queries, "%d") {
parts := make([]string, 0, maxFanoutQueries+1)
for i := 0; i <= maxFanoutQueries; i++ {
parts = append(parts, fmt.Sprintf("q%d", i))
}
queries = strings.Join(parts, ",")
}
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", queries)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam)
})
}
}
// --queries alone is enough: the single-search "--query is required" rule must not
// leak into fanout mode.
func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) != 2 {
t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies))
}
seen := make(map[string]bool, len(stub.CapturedBodies))
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
seen[fmt.Sprint(body["query"])] = true
filter, ok := body["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true {
t.Fatalf("filter must ride along with every query: %#v", body)
}
}
for _, q := range []string{"会议", "日报"} {
if !seen[q] {
t.Fatalf("query %q never issued; saw %v", q, seen)
}
}
}
func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
dedupStub := botSearchStub(botSearchURL+"?page_size=20", "")
dedupStub.Reusable = true
registry.Register(dedupStub)
// " 会议 " and "会议" collapse to one query; the duplicate must not double the
// requests or the rows.
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" {
t.Fatalf("dedup failed: %+v", envelope.Data.Queries)
}
for _, bot := range envelope.Data.Bots {
if bot.MatchedQuery != "会议" {
t.Fatalf("matched_query fidelity: %+v", bot)
}
}
}
func TestBotFanoutConcurrencyCap(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
var inFlight, peak int32
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
}
registry.Register(stub)
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > fanoutConcurrency {
t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak)
}
}
func TestBotFanoutPanicFailsBatch(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
panicCause := errors.New("synthetic test panic")
boom := botSearchStub(botSearchURL, "")
boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }
boom.OnMatch = func(req *http.Request) { panic(panicCause) }
registry.Register(boom)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a panicking query must fail the batch")
}
if !errors.Is(err, panicCause) {
t.Fatalf("panic cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown)
}
if stdout.Len() != 0 {
t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String())
}
}
}
func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL,
Reusable: true,
Status: 500,
Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("every query failing must surface as a command error")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's upstream status and the all-failed mode must both survive,
// so a caller can classify instead of seeing a generic internal error.
for _, want := range []string{"500", "all 2 queries failed"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
const wantNotice = "The query is too long and has been truncated to the first 50 characters for search."
// Assert the notice itself, not just that some row survived: the surviving
// query's server remark has to reach the caller both at the top level and in
// its own sidecar entry.
if envelope.Data.Notice != wantNotice {
t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice)
}
if len(envelope.Data.Queries) != 2 {
t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries)
}
if envelope.Data.Queries[0].Notice != wantNotice {
t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice)
}
if envelope.Data.Queries[0].Error != "" {
t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error)
}
if !strings.Contains(envelope.Data.Queries[1].Error, "500") {
t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error)
}
// Only the surviving query contributes rows.
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
}
func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String())
}
// csv is in the summary format set, so the batch counters belong on stderr.
if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") {
t.Errorf("stderr summary must report the batch counters: %s", stderr.String())
}
if strings.Contains(stderr.String(), "total bots") {
t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String())
}
}
func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
// ndjson is a machine format outside the summary set: every stdout line must
// parse, and the counters must not be mixed in.
for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
if line == "" {
continue
}
var row map[string]interface{}
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatalf("stdout line %d is not JSON: %q", i, line)
}
}
if strings.Contains(stderr.String(), "queries,") {
t.Errorf("ndjson must not emit the summary line: %s", stderr.String())
}
}
// TestBotFanoutCancelledSchedulingFailsQueuedQueries drives the real command so
// the scheduler inside executeBotSearchFanout — not just runOneBotQuery — sees
// the cancellation. Queueing more keywords than fanoutConcurrency while every
// worker is parked keeps all semaphore slots held, so the queued keywords can
// only leave the loop through its ctx.Done() branch.
func TestBotFanoutCancelledSchedulingFailsQueuedQueries(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
var once sync.Once
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(*http.Request) {
once.Do(func() { close(started) })
<-ctx.Done() // hold the slot so later keywords must queue on the semaphore
}
registry.Register(stub)
go func() {
select {
case <-started:
case <-time.After(5 * time.Second): // never leave the workers parked
}
cancel()
}()
queries := make([]string, 0, fanoutConcurrency+3)
for i := 0; i < fanoutConcurrency+3; i++ {
queries = append(queries, fmt.Sprintf("q%d", i))
}
err := mountAndRunContext(t, ctx, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a cancelled batch must surface as a command error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
// TestBotFanoutCancelledContextShortCircuitsBeforeRequest pins the other half:
// a queued worker must fail on the pre-check instead of issuing its request.
func TestBotFanoutCancelledContextShortCircuitsBeforeRequest(t *testing.T) {
results := make([]botFanoutResult, 0, 2)
ctx, cancel := context.WithCancel(context.Background())
cancel()
for i, q := range []string{"会议", "日报"} {
results = append(results, runOneBotQuery(ctx, nil, i, q, nil))
}
for _, r := range results {
if r.ErrMsg == "" {
t.Fatalf("a cancelled context must short-circuit before the request: %+v", r)
}
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("all queries cancelled must surface as an error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议")
setBotSearchFlag(t, cmd, "chat-ids", "oc_a")
setBotSearchFlag(t, cmd, "has-chatted", "true")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime))
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
var preview struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body struct {
Query string `json:"query"`
Filter *struct {
ChatIDs []string `json:"chat_ids"`
HasChatter bool `json:"has_chatter"`
} `json:"filter"`
} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(raw, &preview); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, raw)
}
// Deduped, so the repeated keyword previews once — the preview has to match
// the requests Execute would actually issue.
if len(preview.API) != 2 {
t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw)
}
seen := make([]string, 0, len(preview.API))
for i, call := range preview.API {
if call.Method != "POST" || call.URL != botSearchURL {
t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL)
}
if call.Params["page_size"] != float64(20) {
t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"])
}
if _, ok := call.Params["page_token"]; ok {
t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params)
}
// The filter rides along with every keyword, not just the first.
if call.Body.Filter == nil || !call.Body.Filter.HasChatter ||
len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" {
t.Errorf("api[%d] filter: %+v", i, call.Body.Filter)
}
seen = append(seen, call.Body.Query)
}
if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) {
t.Errorf("previewed keywords: got %v, want [会议 日报]", seen)
}
}
// The summary counts how many queries failed but never says which or why, and
// only json carries queries[].error. Without a per-query line on stderr an agent
// reading csv sees "1 failed" and cannot recover the keyword or the reason.
func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) {
for _, format := range []string{"csv", "table", "pretty", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
for _, want := range []string{"日报", "500"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s",
format, want, stderr.String())
}
}
})
}
}

View File

@@ -0,0 +1,724 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newBotSearchTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("chat-ids", "", "")
cmd.Flags().Bool("has-chatted", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("queries", "", "")
return cmd
}
func botSearchDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
UserOpenId: "ou_self",
}
}
func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) {
t.Helper()
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%q: %v", name, value, err)
}
}
func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if validationErr.Param != wantParam {
t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam)
}
}
// assertBotSearchValidationParams covers the errors that name several flags via
// WithParams; those leave the single Param empty on purpose, so an agent reading
// the envelope sees every flag that could satisfy the requirement.
func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
got := make([]string, 0, len(validationErr.Params))
for _, p := range validationErr.Params {
if p.Reason == "" {
t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name)
}
got = append(got, p.Name)
}
if fmt.Sprint(got) != fmt.Sprint(wantParams) {
t.Fatalf("params: got %v, want %v", got, wantParams)
}
}
func TestValidateBotSearchErrors(t *testing.T) {
chatIDs := make([]string, 101)
for i := range chatIDs {
chatIDs[i] = fmt.Sprintf("oc_%03d", i)
}
tests := []struct {
name string
flags map[string]string
wantParam string
wantParams []string // set instead of wantParam when the error names several flags
wantMessage string
}{
{
name: "keyword missing",
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "query over 50 characters",
flags: map[string]string{"query": strings.Repeat("中", 51)},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
name: "chat ids parse empty",
flags: map[string]string{"query": "x", "chat-ids": " , , "},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')",
},
{
name: "over 100 chat ids",
flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: must be at most 100 entries",
},
{
name: "invalid chat id",
flags: map[string]string{"query": "x", "chat-ids": "bad"},
wantParam: "--chat-ids",
wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)",
},
{
// With a keyword present the keyword errors win, exactly as +search-user
// orders them; the =false check must not be hoisted above these.
name: "mutually exclusive keywords outrank has chatted false",
flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"},
wantParams: []string{"--query", "--queries"},
wantMessage: "--query and --queries are mutually exclusive",
},
{
name: "query length outranks has chatted false",
flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
// With no keyword at all the explicit =false is the more specific mistake,
// so it wins over the missing-keyword error rather than costing a second
// round trip. Matches which error +search-user reports first.
name: "has chatted false without a keyword",
flags: map[string]string{"has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "has chatted false",
flags: map[string]string{"query": "x", "has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "page size below one",
flags: map[string]string{"query": "x", "page-size": "0"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "page size over 30",
flags: map[string]string{"query": "x", "page-size": "31"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "chat ids without a keyword",
flags: map[string]string{"chat-ids": "oc_a"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "has chatted without a keyword",
flags: map[string]string{"has-chatted": "true"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if len(tt.wantParams) > 0 {
assertBotSearchValidationParams(t, err, tt.wantParams)
} else {
assertBotSearchValidationProblem(t, err, tt.wantParam)
}
if err.Error() != tt.wantMessage {
t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestValidateBotSearchPassingCases(t *testing.T) {
tests := []struct {
name string
flags map[string]string
}{
{name: "query only", flags: map[string]string{"query": "x"}},
{name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}},
{name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}},
{name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}},
{name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}},
// An explicitly blank string flag reads as "no filter", matching how
// +search-user treats --user-ids / --queries. Only a non-blank value that
// parses to zero entries is an error.
{name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}},
{name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}},
// Duplicates collapse before the cap is checked, so 101 copies of one chat
// is one entry — matching how --user-ids is resolved for +search-user.
{name: "duplicate chat ids collapse under the cap", flags: map[string]string{
"query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateBotSearchQueryRuneBoundary(t *testing.T) {
for _, tt := range []struct {
name string
query string
wantError bool
}{
{name: "50 CJK characters", query: strings.Repeat("中", 50)},
{name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true},
} {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", tt.query)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if tt.wantError {
assertBotSearchValidationProblem(t, err, "--query")
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestBuildBotSearchBody(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantJSON string
}{
{name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`},
{name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`},
{name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`},
// A blank --chat-ids must not materialize an empty filter object.
{name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`},
// Deduped after normalization, so a repeated id and a URL naming the same
// chat both collapse into one entry instead of burning the server's quota.
{name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
body, err := buildBotSearchBody(runtime)
if err != nil {
t.Fatalf("build body: %v", err)
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
if string(raw) != tt.wantJSON {
t.Fatalf("body: got %s, want %s", raw, tt.wantJSON)
}
})
}
}
func TestParseBotDisplayInfo(t *testing.T) {
tests := []struct {
name string
raw string
wantName string
wantDescription string
wantSegments []string
}{
// Whole name highlighted, description on line two.
{name: "whole name highlighted", raw: "<h>甲乙丙</h>\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}},
// Two highlighted runs split by a plain character: stripping tags has to
// rejoin them into one name.
{name: "two highlighted runs", raw: "<h>甲乙</h>丁<h>丙</h>\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}},
// Highlight at the end plus a trailing newline: line two exists but is empty.
{name: "trailing newline empty description", raw: "戊己的<h>庚辛</h>\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
// Single highlighted character in the middle of the name.
{name: "mid-name highlight", raw: "壬癸<h>子</h>丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}},
{name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}},
{name: "html entities", raw: "<h>Lark</h>部门成员&amp;仓库\n来自飞书&#22810;维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
{name: "html entity in highlight", raw: "名称<h>&amp;</h>工具", wantName: "名称&工具", wantSegments: []string{"&"}},
{name: "empty", raw: "", wantSegments: []string{}},
{name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}},
// A blank first line must not make the description echo the name back and
// swallow the real description on the line after it.
{name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}},
{name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}},
// A highlight with no text carries nothing; an empty match segment is junk
// in the envelope. Which line the name comes from is left unchanged.
{name: "empty highlight yields no segment", raw: "<h></h>\n简介", wantName: "简介", wantSegments: []string{}},
// The non-greedy pattern pairs a stray `<h>` with the next `</h>`, so the
// capture can carry a tag the name and description already dropped.
{name: "nested highlight", raw: "<h>甲<h>乙</h></h>\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{"甲乙"}},
{name: "dangling open tag", raw: "<h><h>甲</h>\n简介", wantName: "甲", wantDescription: "简介", wantSegments: []string{"甲"}},
{name: "unclosed highlight", raw: "<h>甲乙\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{}},
// A literal `<h>` in a name arrives escaped, so it must survive: tags are
// stripped before unescaping. Swapping that order eats the name's own text.
{name: "escaped angle brackets are name text", raw: "名称&lt;h&gt;工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
{name: "escaped angle brackets inside a highlight", raw: "<h>名称&lt;h&gt;</h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{"名称<h>"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, description, segments := parseBotDisplayInfo(tt.raw)
if name != tt.wantName || description != tt.wantDescription {
t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription)
}
if segments == nil {
t.Fatal("match segments must be an empty slice, not nil")
}
if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) {
t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments)
}
})
}
}
func TestProjectBotsMapsEveryField(t *testing.T) {
data := &botSearchAPIData{Items: []botSearchAPIItem{
{
ID: "ou_with_chat",
DisplayInfo: "<h>甲乙丙</h>\n一句话简介",
MetaData: botSearchAPIMeta{
TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true,
},
},
{
ID: "ou_without_chat",
DisplayInfo: "",
MetaData: botSearchAPIMeta{TenantID: "1"},
},
}}
bots := projectBots(data)
if len(bots) != 2 {
t.Fatalf("bots: got %d, want 2", len(bots))
}
first := bots[0]
if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" ||
first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" ||
fmt.Sprint(first.MatchSegments) != "[甲乙丙]" {
t.Fatalf("first bot mapping: %+v", first)
}
second := bots[1]
if second.Name != "" || second.ChatID != "" {
t.Fatalf("empty source fields must stay empty: %+v", second)
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if !strings.Contains(string(raw), `"chat_id":""`) {
t.Fatalf("empty chat_id must still be emitted: %s", raw)
}
if !strings.Contains(string(raw), `"name":""`) {
t.Fatalf("empty name must not fall back to open_id: %s", raw)
}
if strings.Contains(string(raw), `"has_chatted"`) {
t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw)
}
}
func TestProjectBotsEmptySerializesAsArray(t *testing.T) {
bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}})
if bots == nil {
t.Fatal("bots must be an empty slice, not nil")
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if string(raw) != `{"bots":[],"has_more":false}` {
t.Fatalf("response: got %s", raw)
}
}
func botSearchStub(url string, pageToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"has_more": true,
"page_token": pageToken,
"items": []interface{}{
map[string]interface{}{
"id": "ou_bot",
"display_info": "<h>甲乙丙</h>\n一句话简介",
"meta_data": map[string]interface{}{
"tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false,
},
},
},
},
},
}
}
func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out")
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
"--page-size", "25", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("request body: %v", err)
}
if requestBody["query"] != "甲乙" {
t.Fatalf("request query: got %v", requestBody["query"])
}
filter, ok := requestBody["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" {
t.Fatalf("request filter: %#v", requestBody["filter"])
}
var envelope struct {
Data searchBotResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore {
t.Fatalf("response pass-through: %+v", envelope.Data)
}
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
registry.Verify(t)
}
func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
// The stub returns a token; the envelope must still not carry one, matching
// +search-user, which decodes page_token and drops it.
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v", err)
}
data := envelope["data"].(map[string]interface{})
if _, ok := data["page_token"]; ok {
t.Fatalf("page_token must never be surfaced: %v", data)
}
}
func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} {
if !strings.Contains(stdout.String(), column) {
t.Errorf("pretty output missing %q: %s", column, stdout.String())
}
}
for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} {
if strings.Contains(stdout.String(), genericField) {
t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
}
}
// pretty stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("pretty stderr missing %q: %q", want, stderr.String())
}
}
}
func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("table output missing %q: %s", field, stdout.String())
}
}
// table stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("table stderr missing %q: %q", want, stderr.String())
}
}
}
// The old name and assertion here pinned a bug: csv and ndjson were the two
// formats that carried neither has_more in stdout nor a hint on stderr, so a
// machine caller read a truncated result as the whole answer. stdout stays
// data-only; the truncation signal belongs on stderr for every format whose
// stdout has no envelope.
func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) {
for _, format := range []string{"csv", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("%s output missing %q: %s", format, field, stdout.String())
}
}
// stdout must stay data-only, so both the notice and the truncation
// signal have to arrive on stderr.
for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String())
}
}
if strings.Contains(stdout.String(), "more matches exist") {
t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
}
})
}
}
func TestBotSearchPrettyEmptyResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL + "?page_size=20",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "No bots found.") {
t.Fatalf("pretty output: %q", stdout.String())
}
}
func TestBotSearchDryRunMirrorsRequest(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig())
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted",
"--page-size", "25", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body botSearchAPIRequest `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("dry-run JSON: %v", err)
}
if len(envelope.Data.API) != 1 {
t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API))
}
call := envelope.Data.API[0]
if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) {
t.Fatalf("dry-run call: %+v", call)
}
if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter {
t.Fatalf("dry-run body: %+v", call.Body)
}
}
func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) {
_, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}})
if err == nil {
t.Fatal("expected marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem: %+v, ok=%v", problem, ok)
}
}
// Only the json envelope carries data.notice. If the other formats dropped it
// silently, a caller would read a truncated or incomplete result as a complete
// one, so every non-json format has to surface it on stderr instead.
func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", ""))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stdout.String(), notice) {
if format != "json" {
t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String())
}
return
}
if !strings.Contains(stderr.String(), notice) {
t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
// stdout stays pipe-clean: the notice must not be mixed into the rows.
if format == "csv" && strings.Contains(stdout.String(), "notice") {
t.Fatalf("csv stdout must stay data-only: %s", stdout.String())
}
})
}
}
// has_more is the server saying "this is not the whole answer". Only the json
// envelope carries it, so every other format has to say so on stderr or a machine
// caller silently treats a truncated result as complete.
func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) {
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor"))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if format == "json" {
if !strings.Contains(stdout.String(), `"has_more": true`) {
t.Fatalf("json must carry has_more in the envelope: %s", stdout.String())
}
return
}
if !strings.Contains(stderr.String(), "more matches exist") {
t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
})
}
}

View File

@@ -550,6 +550,13 @@ func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
// mountAndRun mounts the shortcut under a parent cobra command and runs it
// with the given args. Mirrors the pattern used in other shortcut packages.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
}
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "contact"}
s.Mount(parent, f)
@@ -559,7 +566,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return parent.ExecuteContext(ctx)
}
// searchUserStub returns a representative user search response with a notice.

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ContactSearchUser,
ContactSearchBot,
ContactGetUser,
}
}

View File

@@ -47,6 +47,14 @@ const defaultLocateDocLimit = 10
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
// maxCommentReplyElements is the element-count cap declared ONLY by the
// reply-create endpoint (POST .../comments/:comment_id/replies), whose
// content.elements schema says "最大元素个数为100". It is enforced only by
// +add-reply. create_v2 (+add-comment) and the reply-update endpoint
// (+update-reply) do not declare this cap, so their inputs are not capped
// here — see the shared parseCommentReplyElements, which stays uncapped.
const maxCommentReplyElements = 100
// The file comment API treats supported Drive file comments as full-file
// comments in the UI, but currently rejects an empty anchor.block_id for file
// targets. TODO: remove this placeholder after the API accepts omitting

View File

@@ -918,6 +918,27 @@ func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
}
}
// create_v2 (+add-comment) uses reply_elements, which does NOT declare the
// 100-element cap that the reply-create endpoint does; +add-comment must not
// reject >100 elements locally.
func TestDriveAddCommentDoesNotCapElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/docx/docxToken",
"--content", "[" + strings.Join(elems, ",") + "]",
"--full-comment",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("+add-comment must not cap element count locally, got %v", err)
}
}
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveAddReplyOp = driveCommentOp{
Label: "comment reply",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveAddReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
}
func (s driveAddReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"content": map[string]interface{}{
"elements": driveReplyV1Elements(s.ReplyElements),
},
}
}
// DriveAddReply replies to an existing comment through the Drive comment
// reply create API (POST .../comments/:comment_id/replies), while accepting
// Wiki URLs/tokens and resolving them to the underlying object.
//
// Note: the documented alternative — POST .../comments with comment_id in the
// body ("如填写,则视为回复已有评论") — does NOT reply on docx in practice; it
// silently creates a new standalone comment instead.
var DriveAddReply = common.Shortcut{
Service: "drive",
Command: "+add-reply",
Description: "Add a reply to an existing comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:create"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveAddReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID to reply to (from drive +list-comments)", Required: true},
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
),
Tips: []string{
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"Whole-document comments (is_whole=true) and solved comments (is_solved=true) do not accept replies; check the comment state via `drive +list-comments` first.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveAddReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveAddReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveAddReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveAddReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveAddReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Adding reply to comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
data, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
)
if err != nil {
return err
}
extra := map[string]interface{}{
"comment_id": spec.CommentID,
"created": true,
}
if replyID := extractDriveCreatedReplyID(data); replyID != "" {
extra["reply_id"] = replyID
}
runtime.Out(driveCommentTargetOutput(target, extra), nil)
return nil
},
}
func readDriveAddReplySpec(runtime *common.RuntimeContext) (driveAddReplySpec, error) {
ref, err := resolveDriveCommentInput(driveAddReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveAddReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveAddReplySpec{}, err
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return driveAddReplySpec{}, err
}
// The reply-create endpoint documents a 100-element cap on content.elements;
// reject over-cap input locally instead of surfacing the opaque [1069302].
if len(replyElements) > maxCommentReplyElements {
return driveAddReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--content has %d elements; the reply endpoint caps content.elements at %d", len(replyElements), maxCommentReplyElements).
WithParam("--content")
}
return driveAddReplySpec{
Ref: ref,
CommentID: commentID,
ReplyElements: replyElements,
}, nil
}
// driveReplyV1Elements converts the simplified +add-comment reply element form
// (text / mention_user / link) to the Drive v1 comment create wire form
// (text_run / person / docs_link).
func driveReplyV1Elements(replyElements []map[string]interface{}) []map[string]interface{} {
elements := make([]map[string]interface{}, 0, len(replyElements))
for _, element := range replyElements {
switch common.GetString(element, "type") {
case "text":
elements = append(elements, map[string]interface{}{
"type": "text_run",
"text_run": map[string]interface{}{"text": common.GetString(element, "text")},
})
case "mention_user":
elements = append(elements, map[string]interface{}{
"type": "person",
"person": map[string]interface{}{"user_id": common.GetString(element, "mention_user")},
})
case "link":
elements = append(elements, map[string]interface{}{
"type": "docs_link",
"docs_link": map[string]interface{}{"url": common.GetString(element, "link")},
})
}
}
return elements
}
// extractDriveCreatedReplyID pulls the created reply ID out of the reply
// create response, tolerating the shapes the API family uses: a top-level
// reply_id, a nested reply object, or a reply_list wrapper.
func extractDriveCreatedReplyID(data map[string]interface{}) string {
if replyID := common.GetString(data, "reply_id"); replyID != "" {
return replyID
}
if reply := common.GetMap(data, "reply"); reply != nil {
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
return replyID
}
}
replyList := common.GetMap(data, "reply_list")
if replyList == nil {
return ""
}
for _, item := range common.GetSlice(replyList, "replies") {
reply, ok := item.(map[string]interface{})
if !ok {
continue
}
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
return replyID
}
}
return ""
}
func buildDriveAddReplyDryRun(spec driveAddReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> add reply to comment").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
Desc("[2] Add reply to comment on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc("1-step request: add reply to comment").
POST("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,393 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveReplyV1Elements(t *testing.T) {
t.Parallel()
elements, err := parseCommentReplyElements(`[
{"type":"text","text":"a<b"},
{"type":"mention_user","mention_user":"ou_123"},
{"type":"link","link":"https://example.com"}
]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := driveReplyV1Elements(elements)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0]["type"] != "text_run" {
t.Fatalf("elements[0].type = %#v, want text_run", got[0]["type"])
}
textRun, ok := got[0]["text_run"].(map[string]interface{})
if !ok {
t.Fatalf("elements[0].text_run is %T, want map", got[0]["text_run"])
}
if textRun["text"] != "a&lt;b" {
t.Fatalf("elements[0].text_run.text = %#v, want escaped a&lt;b", textRun["text"])
}
person, ok := got[1]["person"].(map[string]interface{})
if !ok || got[1]["type"] != "person" {
t.Fatalf("elements[1] = %#v, want person element", got[1])
}
if person["user_id"] != "ou_123" {
t.Fatalf("elements[1].person.user_id = %#v, want ou_123", person["user_id"])
}
docsLink, ok := got[2]["docs_link"].(map[string]interface{})
if !ok || got[2]["type"] != "docs_link" {
t.Fatalf("elements[2] = %#v, want docs_link element", got[2])
}
if docsLink["url"] != "https://example.com" {
t.Fatalf("elements[2].docs_link.url = %#v, want https://example.com", docsLink["url"])
}
}
func TestDriveAddReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"reply": map[string]interface{}{
"reply_id": "reply_9",
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"收到,我来处理"}]`,
"--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("failed to decode captured request body: %v", err)
}
if _, ok := body["comment_id"]; ok {
t.Fatalf("request body must not carry comment_id (it rides in the URL path): %v", body)
}
content := mustMapValue(t, body["content"], "request.content")
elements := mustSliceValue(t, content["elements"], "request.content.elements")
element := mustMapValue(t, elements[0], "request.content.elements[0]")
if got := mustStringField(t, element, "type", "request.content.elements[0].type"); got != "text_run" {
t.Fatalf("request element type = %q, want text_run", got)
}
elementText := mustMapValue(t, element["text_run"], "request.content.elements[0].text_run")
if got := mustStringField(t, elementText, "text", "request.content.elements[0].text_run.text"); got != "收到,我来处理" {
t.Fatalf("text_run.text = %q, want 收到,我来处理", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_9" {
t.Fatalf("reply_id = %q, want reply_9", got)
}
if got := data["created"]; got != true {
t.Fatalf("created = %#v, want true", got)
}
}
func TestDriveAddReplyExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply from wiki"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWiki" {
t.Fatalf("file_token = %q, want docxFromWiki", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
if _, ok := data["reply_id"]; ok {
t.Fatalf("reply_id should be omitted when the response carries none: %v", data)
}
}
func TestDriveAddReplyRejectsUnsupportedTargets(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `unsupported --url resource type "folder"`) {
t.Fatalf("expected unsupported-type error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveAddReplyWikiResolvesToUnsupported(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "mindnote",
"obj_token": "mindnoteFromWiki",
},
},
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote", but comment reply only supports`) {
t.Fatalf("expected wiki-resolution error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestExtractDriveCreatedReplyID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
want string
}{
{name: "nil data", data: nil, want: ""},
{name: "top-level reply_id", data: map[string]interface{}{"reply_id": "r1"}, want: "r1"},
{name: "nested reply object", data: map[string]interface{}{"reply": map[string]interface{}{"reply_id": "r2"}}, want: "r2"},
{name: "nested reply without id falls through", data: map[string]interface{}{"reply": map[string]interface{}{}}, want: ""},
{
name: "reply_list wrapper",
data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{
"not-a-map",
map[string]interface{}{"reply_id": ""},
map[string]interface{}{"reply_id": "r3"},
}}},
want: "r3",
},
{name: "reply_list without match", data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{map[string]interface{}{}}}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := extractDriveCreatedReplyID(tt.data); got != tt.want {
t.Fatalf("extractDriveCreatedReplyID() = %q, want %q", got, tt.want)
}
})
}
}
func TestDriveAddReplyRejectsUnsafeCommentID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("expected comment-id validation error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--comment-id")
}
func TestDriveAddReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 1069307,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveAddReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
t.Fatalf("api[1].url = %q, want replies URL with comment ID", got)
}
}
func TestDriveAddReplyInvalidContent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `not-json`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") {
t.Fatalf("expected content JSON error, got %v", err)
}
}
func TestDriveAddReplyRejectsTooManyElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", "[" + strings.Join(elems, ",") + "]",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "caps content.elements at 100") {
t.Fatalf("expected 100-element cap error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--content")
}
func TestDriveAddReplyAcceptsMaxElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 100)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", "[" + strings.Join(elems, ",") + "]",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("100 elements should be accepted, got %v", err)
}
}
func TestDriveAddReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
t.Fatalf("api[0].url = %q, want reply create URL with comment ID", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if _, ok := body["comment_id"]; ok {
t.Fatalf("api[0].body must not carry comment_id: %v", body)
}
content := mustMapValue(t, body["content"], "api[0].body.content")
if _, ok := content["elements"]; !ok {
t.Fatalf("api[0].body.content.elements missing: %v", body)
}
}

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -13,72 +14,137 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// permApplyTypes is the authoritative list of type values the apply-permission
// endpoint accepts for its required `type` query parameter.
var permApplyTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "slides",
type permApplyResourceKind struct {
Type string
Path string
}
// permApplyURLMarkers maps document URL path markers to the `type` value the
// apply-permission endpoint expects. Markers are disjoint strings (each begins
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
var permApplyURLMarkers = []struct {
Marker string
Type string
}{
{"/wiki/", "wiki"},
{"/docx/", "docx"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/file/", "file"},
{"/mindnote/", "mindnote"},
{"/slides/", "slides"},
{"/doc/", "doc"},
// permApplyResourceKinds is the authoritative target contract for the
// apply-permission endpoint: accepted types and their URL root paths.
var permApplyResourceKinds = []permApplyResourceKind{
{Type: "doc", Path: "/doc/"},
{Type: "sheet", Path: "/sheets/"},
{Type: "file", Path: "/file/"},
{Type: "wiki", Path: "/wiki/"},
{Type: "bitable", Path: "/base/"},
{Type: "bitable", Path: "/bitable/"},
{Type: "docx", Path: "/docx/"},
{Type: "mindnote", Path: "/mindnote/"},
{Type: "slides", Path: "/slides/"},
{Type: "apps", Path: "/page/"},
}
var permApplyTypes = func() []string {
types := make([]string, 0, len(permApplyResourceKinds))
seen := make(map[string]struct{}, len(permApplyResourceKinds))
for _, resourceKind := range permApplyResourceKinds {
if _, ok := seen[resourceKind.Type]; ok {
continue
}
seen[resourceKind.Type] = struct{}{}
types = append(types, resourceKind.Type)
}
return types
}()
func permApplyTypeAllowed(docType string) bool {
for _, allowedType := range permApplyTypes {
if docType == allowedType {
return true
}
}
return false
}
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
// value that may be either a bare token or a full document URL, plus an
// optional explicit --type. Explicit --type wins over URL inference.
// optional explicit --type. A URL's path and explicit --type must agree.
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
raw = strings.TrimSpace(raw)
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if explicitType != "" && !permApplyTypeAllowed(explicitType) {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --type %q: allowed values are %s",
explicitType,
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
if strings.Contains(raw, "://") {
for _, m := range permApplyURLMarkers {
if tok, ok := extractURLToken(raw, m.Marker); ok {
token = tok
if explicitType == "" {
docType = m.Type
}
break
}
}
if token == "" {
ref, ok := parsePermApplyResourceURL(raw)
if !ok {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/, /page/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
token, docType = ref.Token, ref.Type
if explicitType != "" && explicitType != docType {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
docType,
).WithParam("--type")
}
} else {
token = raw
}
if explicitType != "" {
docType = explicitType
}
if docType == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
if err := validatePermApplyToken(token); err != nil {
return "", "", err
}
return token, docType, nil
}
func parsePermApplyResourceURL(rawURL string) (common.ResourceRef, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return common.ResourceRef{}, false
}
escapedPath := parsed.EscapedPath()
for _, resourceKind := range permApplyResourceKinds {
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
continue
}
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
if escapedToken == "" || strings.Contains(escapedToken, "/") {
return common.ResourceRef{}, false
}
token, err := url.PathUnescape(escapedToken)
if err != nil || token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func validatePermApplyToken(token string) error {
if err := validate.ResourceName(token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
if token == "." || strings.Contains(token, "/") {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token must be a non-dot single path segment",
).WithParam("--token")
}
return nil
}
// DriveApplyPermission applies to the document owner for view or edit access
// on behalf of the invoking user. Matches the open-apis endpoint
// /open-apis/drive/v1/permissions/:token/members/apply.
@@ -88,16 +154,19 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
var DriveApplyPermission = common.Shortcut{
Service: "drive",
Command: "+apply-permission",
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
Description: "Apply to the owner for view or edit permission on a Drive resource",
Risk: "write",
Scopes: []string{"docs:permission.member:apply"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "token", Desc: "target token or URL (docx/sheets/base/file/wiki/doc/mindnote/slides/page)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes},
{Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}},
{Name: "remark", Desc: "optional note shown on the request card sent to the owner"},
},
Tips: []string{
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
return err
@@ -109,7 +178,7 @@ var DriveApplyPermission = common.Shortcut{
}
body := buildPermApplyBody(runtime)
return common.NewDryRunAPI().
Desc("Apply to document owner for access").
Desc("Apply to resource owner for access").
POST("/open-apis/drive/v1/permissions/:token/members/apply").
Params(map[string]interface{}{"type": docType}).
Body(body).
@@ -131,7 +200,7 @@ var DriveApplyPermission = common.Shortcut{
body,
)
if err != nil {
return err
return decoratePermApplyError(err)
}
runtime.Out(data, nil)
return nil
@@ -148,3 +217,34 @@ func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} {
}
return body
}
func decoratePermApplyError(err error) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
guidance := permApplyErrorGuidance(problem.Code)
if guidance == "" {
return err
}
if problem.Hint == "" {
problem.Hint = guidance
} else if !strings.Contains(problem.Hint, guidance) {
problem.Hint += "; " + guidance
}
return err
}
func permApplyErrorGuidance(code int) string {
switch code {
case 1063006:
return "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait for the daily quota to reset before retrying"
case 1063007:
return "this document does not accept a permission-apply request; verify the target and requested permission, or contact the owner directly"
default:
return ""
}
}

View File

@@ -5,9 +5,11 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -33,6 +35,18 @@ func TestResolvePermApplyTarget_BareTokenWithType(t *testing.T) {
}
}
func TestResolvePermApplyTarget_BareTokenWithAppsType(t *testing.T) {
t.Parallel()
token, docType, err := resolvePermApplyTarget("appBareToken", "apps")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "appBareToken" || docType != "apps" {
t.Fatalf("got token=%q type=%q, want appBareToken/apps", token, docType)
}
}
func TestResolvePermApplyTarget_URLInference(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -50,6 +64,7 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
{"mindnote", "https://example.feishu.cn/mindnote/mnTok444", "mnTok444", "mindnote"},
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
{"apps page", "https://example.feishu.cn/page/appMetaTok/?from=share", "appMetaTok", "apps"},
}
for _, temp := range tests {
tt := temp
@@ -66,15 +81,100 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
}
}
func TestResolvePermApplyTarget_ExplicitTypeOverridesURL(t *testing.T) {
func TestResolvePermApplyTarget_RejectsMalformedPageURL(t *testing.T) {
t.Parallel()
// Even though the URL marker is /docx/, an explicit --type wins.
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
if err != nil {
t.Fatalf("unexpected error: %v", err)
token, docType, err := resolvePermApplyTarget("https://example.feishu.cn/page/?from=share", "")
if err == nil || !strings.Contains(err.Error(), "could not infer token") {
t.Fatalf("expected page token inference error, got token=%q type=%q error=%v", token, docType, err)
}
if token != "doxTok123" || docType != "wiki" {
t.Fatalf("got (%q,%q), want (doxTok123,wiki)", token, docType)
}
func TestResolvePermApplyTarget_RejectsAppsMarkerOutsidePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
}{
{
name: "query",
raw: "https://example.feishu.cn/share?redirect=/page/appMetaTok",
},
{
name: "fragment",
raw: "https://example.feishu.cn/share#/page/appMetaTok",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
token, docType, err := resolvePermApplyTarget(tt.raw, "")
if err == nil {
t.Fatalf("expected URL path inference error, got token=%q type=%q", token, docType)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error category/subtype = %q/%q, want %q/%q",
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--token" {
t.Fatalf("error param = %q, want %q", validationErr.Param, "--token")
}
})
}
}
func TestResolvePermApplyTarget_RejectsConflictingURLType(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
if err == nil || !strings.Contains(err.Error(), "conflicts with URL path type") {
t.Fatalf("expected URL type conflict error, got: %v", err)
}
}
func TestResolvePermApplyTarget_RejectsUnsafeOrAmbiguousTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
type_ string
}{
{"bare traversal token", "..", "docx"},
{"bare dot token", ".", "docx"},
{"URL traversal token", "https://example.feishu.cn/docx/../victim", ""},
{"marker outside resource root", "https://example.feishu.cn/share/docx/doxUnexpected", ""},
{"encoded path separator", "https://example.feishu.cn/docx/doxTarget%2Fother", ""},
{"encoded query separator", "https://example.feishu.cn/docx/doxTarget%3Fother", ""},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget(tt.raw, tt.type_)
if err == nil {
t.Fatalf("resolvePermApplyTarget(%q, %q) unexpectedly succeeded", tt.raw, tt.type_)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--token" {
t.Fatalf("error param = %q, want --token", validationErr.Param)
}
})
}
}
@@ -150,6 +250,33 @@ func TestDriveApplyPermission_DryRunInfersTypeFromURL(t *testing.T) {
}
}
func TestDriveApplyPermission_DryRunAcceptsAppsBareToken(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveApplyPermission, []string{
"+apply-permission",
"--token", "appBareToken",
"--type", "apps",
"--perm", "edit",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v1/permissions/appBareToken/members/apply",
`"apps"`,
`"edit"`,
`"appBareToken"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDriveApplyPermission_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Stub URL includes "?type=docx" — the stub only matches when the request
@@ -196,6 +323,11 @@ func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
Status: 400,
Body: map[string]interface{}{
"code": 1063007, "msg": "request not applicable",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "server says requests are disabled"},
},
},
},
})
@@ -212,6 +344,18 @@ func TestDriveApplyPermission_ExecuteNotApplicableHint(t *testing.T) {
if !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("expected surfaced server message, got: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
}
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeInvalidParameters || problem.Code != 1063007 {
t.Fatalf("problem = %+v, want api/invalid_parameters code 1063007", problem)
}
for _, want := range []string{"server says requests are disabled", "does not accept a permission-apply request", "contact the owner"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint missing %q: %q", want, problem.Hint)
}
}
}
func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
@@ -235,4 +379,17 @@ func TestDriveApplyPermission_ExecuteRateLimitHint(t *testing.T) {
if err == nil {
t.Fatal("expected error for 1063006")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
}
if problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeRateLimit || problem.Code != 1063006 {
t.Fatalf("problem = %+v, want api/rate_limit code 1063006", problem)
}
if problem.Retryable {
t.Fatalf("problem.Retryable = true, want false for the daily per-document quota")
}
if !strings.Contains(problem.Hint, "at most 5 times per day") {
t.Fatalf("hint missing daily quota guidance: %q", problem.Hint)
}
}

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// driveBatchQueryCommentsMaxIDs mirrors the server-side cap on comment_ids
// per batch_query call.
const driveBatchQueryCommentsMaxIDs = 100
var driveBatchQueryCommentsOp = driveCommentOp{
Label: "comments batch query",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveBatchQueryCommentsSpec struct {
Ref driveCommentRef
CommentIDs []string
NeedReaction bool
NeedRelation bool
}
// RequestBody assembles the batch_query body for the resolved fileType.
// need_relation is absent from the platform metadata for this endpoint but
// honored live (same undocumented parameter +list-comments already uses);
// only docx returns relation data, so it is sent for docx targets only.
func (s driveBatchQueryCommentsSpec) RequestBody(fileType string) map[string]interface{} {
body := map[string]interface{}{
"comment_ids": s.CommentIDs,
}
if s.NeedReaction {
body["need_reaction"] = true
}
if s.NeedRelation && fileType == "docx" {
body["need_relation"] = true
}
return body
}
// DriveBatchQueryComments fetches comments by ID through the Drive comment
// batch_query API, while accepting Wiki URLs/tokens and resolving them to the
// underlying object.
var DriveBatchQueryComments = common.Shortcut{
Service: "drive",
Command: "+batch-query-comments",
Description: "Batch get comments by comment ID for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveBatchQueryCommentsOp),
common.Flag{Name: "comment-ids", Type: "string_slice", Desc: fmt.Sprintf("comment IDs to fetch (comma-separated or repeated flag, max %d)", driveBatchQueryCommentsMaxIDs), Required: true},
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
common.Flag{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
),
Tips: []string{
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"--comment-ids accepts comma-separated values and repeated flags, up to 100 IDs per call.",
"--need-relation returns the docx comment anchor (items[].relation with the block position); see the lark-drive comment-location guide.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveBatchQueryCommentsSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveBatchQueryCommentsSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveBatchQueryCommentsDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveBatchQueryCommentsSpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveBatchQueryCommentsOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Batch querying %d comment(s) in %s...\n", len(spec.CommentIDs), common.MaskToken(target.FileToken))
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments/batch_query", validate.EncodePathSegment(target.FileToken))
data, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(target.FileType),
)
if err != nil {
return err
}
items := driveCommentItems(data)
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"items": items,
"count": len(items),
}), nil)
return nil
},
}
func readDriveBatchQueryCommentsSpec(runtime *common.RuntimeContext) (driveBatchQueryCommentsSpec, error) {
ref, err := resolveDriveCommentInput(driveBatchQueryCommentsOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveBatchQueryCommentsSpec{}, err
}
ids, err := normalizeDriveCommentIDs(runtime.StrSlice("comment-ids"))
if err != nil {
return driveBatchQueryCommentsSpec{}, err
}
return driveBatchQueryCommentsSpec{
Ref: ref,
CommentIDs: ids,
NeedReaction: runtime.Bool("need-reaction"),
NeedRelation: runtime.Bool("need-relation"),
}, nil
}
func normalizeDriveCommentIDs(raw []string) ([]string, error) {
ids := make([]string, 0, len(raw))
for i, id := range raw {
id = strings.TrimSpace(id)
if id == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids element #%d is empty", i+1).WithParam("--comment-ids")
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids must contain at least one comment ID").WithParam("--comment-ids")
}
if len(ids) > driveBatchQueryCommentsMaxIDs {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--comment-ids accepts at most %d comment IDs per call (got %d)", driveBatchQueryCommentsMaxIDs, len(ids)).WithParam("--comment-ids")
}
return ids, nil
}
func buildDriveBatchQueryCommentsDryRun(spec driveBatchQueryCommentsSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
// The wiki obj_type is unknown until step 1 resolves, so RequestBody
// cannot decide the docx-only need_relation gate here; surface it as a
// placeholder the same way +list-comments does.
body := spec.RequestBody("<obj_type from step 1>")
if spec.NeedRelation {
body["need_relation"] = "<sent only when obj_type is docx>"
}
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> batch query comments").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/batch_query").
Desc("[2] Batch query comments on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(body)
}
return common.NewDryRunAPI().
Desc("1-step request: batch query comments").
POST("/open-apis/drive/v1/files/:file_token/comments/batch_query").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody(spec.Ref.Type)).
Set("file_token", spec.Ref.Token)
}

View File

@@ -0,0 +1,560 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestNormalizeDriveCommentIDs(t *testing.T) {
t.Parallel()
got, err := normalizeDriveCommentIDs([]string{" c1 ", "c2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 || got[0] != "c1" || got[1] != "c2" {
t.Fatalf("normalizeDriveCommentIDs = %v, want [c1 c2]", got)
}
if _, err := normalizeDriveCommentIDs(nil); err == nil || !strings.Contains(err.Error(), "at least one") {
t.Fatalf("expected at-least-one error, got %v", err)
}
if _, err := normalizeDriveCommentIDs([]string{"c1", " "}); err == nil || !strings.Contains(err.Error(), "element #2 is empty") {
t.Fatalf("expected empty-element error, got %v", err)
}
tooMany := make([]string, driveBatchQueryCommentsMaxIDs+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("c%d", i)
}
_, err = normalizeDriveCommentIDs(tooMany)
if err == nil || !strings.Contains(err.Error(), "at most 100") {
t.Fatalf("expected max-IDs error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--comment-ids")
}
func TestDriveBatchQueryCommentsExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_1", "is_solved": false},
{"comment_id": "comment_2", "is_solved": true},
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1,comment_2",
"--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("failed to decode captured request body: %v", err)
}
ids := mustSliceValue(t, body["comment_ids"], "request.comment_ids")
if len(ids) != 2 || ids[0] != "comment_1" || ids[1] != "comment_2" {
t.Fatalf("request comment_ids = %v, want [comment_1 comment_2]", ids)
}
if _, ok := body["need_reaction"]; ok {
t.Fatalf("request should omit need_reaction by default: %v", body)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
t.Fatalf("file_token = %q, want docxResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := data["count"]; got != float64(2) {
t.Fatalf("count = %#v, want 2", got)
}
if _, ok := data["wiki_token"]; ok {
t.Fatalf("wiki_token should be omitted for direct targets: %v", data)
}
}
func TestDriveBatchQueryCommentsExecuteWikiWithReaction(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("token"); got != "wikiResource" {
t.Errorf("wiki token = %q, want wikiResource", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": "sheetFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "sheet" {
t.Errorf("file_type = %q, want sheet", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--token", "wikiResource",
"--type", "wiki",
"--comment-ids", "comment_1",
"--need-reaction",
"--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("failed to decode captured request body: %v", err)
}
if got := body["need_reaction"]; got != true {
t.Fatalf("request need_reaction = %#v, want true", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "sheetFromWiki" {
t.Fatalf("file_token = %q, want sheetFromWiki", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
t.Fatalf("file_type = %q, want sheet", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveBatchQueryCommentsExecuteAppsPageURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/appsPageResource/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "apps" {
t.Errorf("file_type = %q, want apps", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.feishu.cn/page/appsPageResource/",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "apps" {
t.Fatalf("file_type = %q, want apps", got)
}
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "appsPageResource" {
t.Fatalf("file_token = %q, want appsPageResource", got)
}
}
func TestDriveBatchQueryCommentsExecuteBaseURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/baseResource/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{{"comment_id": "comment_1"}},
},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/base/baseResource",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("file_type = %q, want bitable", got)
}
}
func TestDriveBatchQueryCommentsWikiResolvesToUnsupported(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "mindnote",
"obj_token": "mindnoteToken",
},
},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote"`) {
t.Fatalf("expected wiki-resolution error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveBatchQueryCommentsExecuteBaseAliasType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/baseToken/comments/batch_query",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable (base alias normalized)", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []map[string]interface{}{}},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--token", "baseToken",
"--type", "base",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveBatchQueryCommentsValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "url and token mutually exclusive",
args: []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--token", "docxResource",
"--comment-ids", "comment_1",
},
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "blank comment id element",
args: []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", " ",
},
wantErr: "element #1 is empty",
wantParam: "--comment-ids",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveBatchQueryCommentsPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
Body: map[string]interface{}{
"code": 1069307,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_404",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveBatchQueryCommentsDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1,comment_2",
"--need-reaction",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/batch_query") {
t.Fatalf("api[0].url = %q, want resolved batch_query URL", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if got := body["need_reaction"]; got != true {
t.Fatalf("api[0].body.need_reaction = %#v, want true", got)
}
}
func TestDriveBatchQueryCommentsDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-ids", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
t.Fatalf("api[1].method = %q, want POST", got)
}
body := mustMapValue(t, step2["body"], "api[1].body")
ids := mustSliceValue(t, body["comment_ids"], "api[1].body.comment_ids")
if len(ids) != 1 || ids[0] != "comment_1" {
t.Fatalf("api[1].body.comment_ids = %v, want [comment_1]", ids)
}
}
func TestDriveBatchQueryCommentsDryRunWikiNeedRelation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-ids", "comment_1",
"--need-relation",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
body := mustMapValue(t, step2["body"], "api[1].body")
if got := body["need_relation"]; got != "<sent only when obj_type is docx>" {
t.Fatalf("api[1].body.need_relation = %#v, want conditional placeholder", got)
}
}
func TestDriveBatchQueryCommentsOmittedItemsNormalized(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
items, ok := data["items"].([]interface{})
if !ok {
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}
func TestDriveBatchQueryCommentsNeedRelationDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []interface{}{}},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-ids", "comment_1",
"--need-relation",
"--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("failed to decode captured request body: %v", err)
}
if got := body["need_relation"]; got != true {
t.Fatalf("request need_relation = %#v, want true", got)
}
}
func TestDriveBatchQueryCommentsNeedRelationIgnoredForNonDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/sheetResource/comments/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []interface{}{}},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveBatchQueryComments, []string{
"+batch-query-comments",
"--url", "https://example.larksuite.com/sheets/sheetResource",
"--comment-ids", "comment_1",
"--need-relation",
"--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("failed to decode captured request body: %v", err)
}
if _, ok := body["need_relation"]; ok {
t.Fatalf("need_relation must be omitted for non-docx targets: %v", body)
}
}

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"slices"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// driveCommentOp describes one comment-family shortcut for the shared
// --url/--token/--type input resolution. Label appears in error messages;
// Types lists the wire file_type values the underlying endpoint accepts.
// Wiki URLs/tokens are always accepted as input and unwrapped to the
// underlying document, which must then land in Types.
type driveCommentOp struct {
Label string
Types []string
}
func (op driveCommentOp) supports(fileType string) bool {
return slices.Contains(op.Types, fileType)
}
// inputTypeList renders the values accepted as input (wire types plus wiki).
func (op driveCommentOp) inputTypeList() string {
return strings.Join(op.flagEnum(), ", ")
}
// targetTypeList renders the wire types the endpoint accepts (wiki excluded).
func (op driveCommentOp) targetTypeList() string {
return strings.Join(op.Types, ", ")
}
// flagEnum returns the Enum set for the --type flag: the endpoint's wire
// types plus wiki (resolved to a wire type before the API call) and the
// base product-name alias when bitable is supported (normalized to bitable).
func (op driveCommentOp) flagEnum() []string {
enum := make([]string, 0, len(op.Types)+2)
for _, t := range op.Types {
enum = append(enum, t)
if t == "bitable" {
enum = append(enum, "base")
}
}
return append(enum, "wiki")
}
// driveCommentRef is the parsed --url/--token/--type input before wiki unwrapping.
type driveCommentRef struct {
Token string
Type string
SourceFlag string
}
// driveCommentTarget is the underlying document a comment API call targets.
type driveCommentTarget struct {
FileToken string
FileType string
WikiToken string // non-empty when the input was a wiki node
}
// driveCommentTargetFlags returns the shared --url/--token/--type flag trio
// used by the comment-family shortcuts that resolve a document target.
func driveCommentTargetFlags(op driveCommentOp) []common.Flag {
return []common.Flag{
{Name: "url", Desc: fmt.Sprintf("recommended: Lark/Feishu document URL (%s); Wiki URLs are unwrapped automatically", op.inputTypeList())},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: op.flagEnum()},
}
}
// resolveDriveCommentInput parses --url/--token/--type into a driveCommentRef,
// mirroring +list-comments input handling: --url and --token are mutually
// exclusive, URLs are parsed for type+token, bare tokens require --type, and
// wiki is always accepted for later unwrapping.
func resolveDriveCommentInput(op driveCommentOp, urlInput, tokenInput, explicitType string) (driveCommentRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveCommentType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveCommentType(ref.Type)
if inputType != "" && inputType != refType {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if refType != "wiki" && !op.supports(refType) {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; %s supports %s",
sourceFlag,
refType,
op.Label,
op.inputTypeList(),
).WithParam(sourceFlag)
}
return driveCommentRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if token, ok := parseDriveListCommentsAppsURL(raw); ok {
const refType = "apps"
if inputType != "" && inputType != refType {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if !op.supports(refType) {
return driveCommentRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; %s supports %s",
sourceFlag,
refType,
op.Label,
op.inputTypeList(),
).WithParam(sourceFlag)
}
return driveCommentRef{Token: token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: %s)", sourceFlag, op.inputTypeList()).WithParam("--type")
}
if inputType != "wiki" && !op.supports(inputType) {
return driveCommentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: %s", inputType, op.inputTypeList()).WithParam("--type")
}
return driveCommentRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
// normalizeDriveCommentType maps compatibility aliases to wire values
// (base → bitable) so type checks and error messages use one vocabulary.
func normalizeDriveCommentType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
// resolveDriveCommentTarget unwraps wiki refs to the underlying document via
// wiki get_node and validates the resolved type against op.Types.
func resolveDriveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, op driveCommentOp, ref driveCommentRef) (driveCommentTarget, error) {
if ref.Type != "wiki" {
return driveCommentTarget{FileToken: ref.Token, FileType: ref.Type}, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return driveCommentTarget{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveCommentType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return driveCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType == "wiki" || !op.supports(objType) {
return driveCommentTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but %s only supports %s",
objType,
op.Label,
op.targetTypeList(),
).WithParam(ref.SourceFlag)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return driveCommentTarget{FileToken: objToken, FileType: objType, WikiToken: ref.Token}, nil
}
// validateDriveCommentPathID validates a comment/reply identifier destined
// for a URL path segment.
func validateDriveCommentPathID(value, flagName string) error {
if strings.TrimSpace(value) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must not be empty", flagName).WithParam(flagName)
}
if err := validate.ResourceName(strings.TrimSpace(value), flagName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName)
}
return nil
}
// driveCommentItems extracts data.items for output, normalizing a missing or
// null field to an empty slice: emitting the server's shape verbatim would
// surface "items": null, which breaks jq consumers iterating .data.items[].
func driveCommentItems(data map[string]interface{}) []interface{} {
if items := common.GetSlice(data, "items"); items != nil {
return items
}
return []interface{}{}
}
// driveCommentTargetOutput assembles the output fields shared by the
// comment-family shortcuts: the resolved target plus the wiki origin, if any.
func driveCommentTargetOutput(target driveCommentTarget, extra map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
for key, value := range extra {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func assertDriveCommentValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
}
// assertDriveCommentAPIError asserts the error kept the typed API contract:
// CallAPITyped errors must reach the caller unchanged, message-only checks
// would still pass if a refactor wrapped them into untyped errors.
func assertDriveCommentAPIError(t *testing.T, err error, wantCode int) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype == "" {
t.Fatalf("subtype is empty, want populated")
}
if problem.Code != wantCode {
t.Fatalf("code = %d, want %d", problem.Code, wantCode)
}
}
func TestResolveDriveCommentInput(t *testing.T) {
t.Parallel()
op := driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "sheet", "file", "slides"}}
docOnlyOp := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
tests := []struct {
name string
op driveCommentOp
urlInput string
rawInput string
docType string
wantToken string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
op: op,
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
wantToken: "docxResource",
wantType: "docx",
},
{
name: "url wiki always accepted",
op: docOnlyOp,
urlInput: "https://example.larksuite.com/wiki/wikiResource",
wantToken: "wikiResource",
wantType: "wiki",
},
{
name: "token flag also accepts url",
op: op,
rawInput: "https://example.larksuite.com/sheets/sheetResource",
wantToken: "sheetResource",
wantType: "sheet",
},
{
name: "bare token with type",
op: op,
rawInput: "docxResource",
docType: "docx",
wantToken: "docxResource",
wantType: "docx",
},
{
name: "bare wiki token",
op: docOnlyOp,
rawInput: "wikiResource",
docType: "wiki",
wantToken: "wikiResource",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
op: op,
urlInput: "https://example.larksuite.com/docx/docxResource",
rawInput: "docxResource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "missing input",
op: op,
wantErr: "specify --url or --token",
wantParam: "--url",
},
{
name: "bare token needs type",
op: op,
rawInput: "docxResource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
op: op,
urlInput: "https://example.larksuite.com/wiki/wikiResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unsupported url type",
op: op,
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
wantErr: "unsupported --url resource type",
wantParam: "--url",
},
{
name: "unsupported url type for doc-only op",
op: docOnlyOp,
urlInput: "https://example.larksuite.com/sheets/sheetResource",
wantErr: "comment reply supports doc, docx, wiki",
wantParam: "--url",
},
{
name: "apps page url",
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
urlInput: "https://example.feishu.cn/page/appsPageResource/",
wantToken: "appsPageResource",
wantType: "apps",
},
{
name: "apps page url rejected by op without apps",
op: docOnlyOp,
urlInput: "https://example.feishu.cn/page/appsPageResource",
wantErr: `unsupported --url resource type "apps"`,
wantParam: "--url",
},
{
name: "apps page url conflicts with explicit type",
op: driveCommentOp{Label: "comments batch query", Types: []string{"doc", "docx", "apps"}},
urlInput: "https://example.feishu.cn/page/appsPageResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "base alias normalized in error",
op: op,
urlInput: "https://example.larksuite.com/base/baseResource",
wantErr: `unsupported --url resource type "bitable"`,
wantParam: "--url",
},
{
name: "unrecognized url",
op: op,
urlInput: "https://example.com/unknown/path",
wantErr: "unsupported --url URL",
wantParam: "--url",
},
{
name: "bare token with path fragments",
op: op,
rawInput: "abc/def",
docType: "docx",
wantErr: "invalid bare token",
wantParam: "--token",
},
{
name: "invalid explicit type",
op: docOnlyOp,
rawInput: "sheetResource",
docType: "sheet",
wantErr: "invalid --type",
wantParam: "--type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveCommentInput(tt.op, tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantToken || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
}
})
}
}
func TestValidateDriveCommentPathID(t *testing.T) {
t.Parallel()
if err := validateDriveCommentPathID("7457000000000000001", "--comment-id"); err != nil {
t.Fatalf("unexpected error for valid ID: %v", err)
}
tests := []struct {
name string
value string
wantErr string
}{
{name: "empty", value: " ", wantErr: "must not be empty"},
{name: "path traversal", value: "../admin", wantErr: "path traversal"},
{name: "url metacharacters", value: "abc?x=1", wantErr: "invalid characters"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCommentPathID(tt.value, "--comment-id")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, "--comment-id")
})
}
}
func TestDriveCommentOpTypeHelpers(t *testing.T) {
t.Parallel()
op := driveCommentOp{Label: "comment reply", Types: []string{"doc", "docx"}}
if got := op.inputTypeList(); got != "doc, docx, wiki" {
t.Fatalf("inputTypeList() = %q, want %q", got, "doc, docx, wiki")
}
if got := op.targetTypeList(); got != "doc, docx" {
t.Fatalf("targetTypeList() = %q, want %q", got, "doc, docx")
}
if got := op.flagEnum(); len(got) != 3 || got[2] != "wiki" {
t.Fatalf("flagEnum() = %v, want types plus trailing wiki", got)
}
if !op.supports("docx") || op.supports("sheet") || op.supports("wiki") {
t.Fatalf("supports() misclassified: docx=%v sheet=%v wiki=%v", op.supports("docx"), op.supports("sheet"), op.supports("wiki"))
}
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveDeleteReplyOp = driveCommentOp{
Label: "reply delete",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveDeleteReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyID string
}
// DriveDeleteReply deletes a reply of a comment through the Drive comment
// reply delete API, while accepting Wiki URLs/tokens and resolving them to
// the underlying object.
var DriveDeleteReply = common.Shortcut{
Service: "drive",
Command: "+delete-reply",
Description: "Delete a reply of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "high-risk-write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveDeleteReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID the reply belongs to (from drive +list-comments)", Required: true},
common.Flag{Name: "reply-id", Desc: "reply ID to delete (from drive +list-comments items[].reply_list.replies[].reply_id)", Required: true},
),
Tips: []string{
"Reply IDs come from `drive +list-comments` (items[].reply_list.replies[].reply_id).",
"Deletion is permanent; there is no undo or trash for comment replies.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveDeleteReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveDeleteReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveDeleteReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveDeleteReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveDeleteReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Deleting reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
validate.EncodePathSegment(spec.ReplyID),
)
if _, err := runtime.CallAPITyped(
"DELETE",
path,
map[string]interface{}{"file_type": target.FileType},
nil,
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"reply_id": spec.ReplyID,
"deleted": true,
}), nil)
return nil
},
}
func readDriveDeleteReplySpec(runtime *common.RuntimeContext) (driveDeleteReplySpec, error) {
ref, err := resolveDriveCommentInput(driveDeleteReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveDeleteReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveDeleteReplySpec{}, err
}
replyID := strings.TrimSpace(runtime.Str("reply-id"))
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
return driveDeleteReplySpec{}, err
}
return driveDeleteReplySpec{
Ref: ref,
CommentID: commentID,
ReplyID: replyID,
}, nil
}
func buildDriveDeleteReplyDryRun(spec driveDeleteReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> delete reply").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
DELETE("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
Desc("[2] Delete reply on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}
return common.NewDryRunAPI().
Desc("1-step request: delete reply").
DELETE("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveDeleteReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
t.Fatalf("reply_id = %q, want reply_2", got)
}
if got := data["deleted"]; got != true {
t.Fatalf("deleted = %#v, want true", got)
}
}
func TestDriveDeleteReplyExecuteViaWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "file",
"obj_token": "fileFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fileFromWiki/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "file" {
t.Errorf("file_type = %q, want file", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
t.Fatalf("file_type = %q, want file", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveDeleteReplyValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe reply id",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "../reply",
},
wantErr: "path traversal",
wantParam: "--reply-id",
},
{
name: "empty reply id",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", " ",
},
wantErr: "--reply-id must not be empty",
wantParam: "--reply-id",
},
{
name: "unsafe comment id",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--reply-id", "reply_2",
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "unsupported url type",
args: []string{
"+delete-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
},
wantErr: "reply delete supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDeleteReply, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveDeleteReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
Body: map[string]interface{}{
"code": 1069307,
"msg": "reply not found",
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "reply not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveDeleteReplyWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx"},
},
},
})
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--yes",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveDeleteReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "DELETE" {
t.Fatalf("api[0].method = %q, want DELETE", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
t.Fatalf("api[0].url = %q, want resolved path segments", got)
}
}
func TestDriveDeleteReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDeleteReply, []string{
"+delete-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "DELETE" {
t.Fatalf("api[1].method = %q, want DELETE", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
}
}

View File

@@ -5,8 +5,11 @@ package drive
import (
"context"
"errors"
"fmt"
"net/http"
"path"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -16,47 +19,180 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const driveMetadataReadScope = "drive:drive.metadata:readonly"
type driveDownloadOutputPathValidator func(string) error
func driveDownloadNormalizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
name = strings.ReplaceAll(name, "\\", "/")
name = path.Base(name)
if name == "" || name == "." || name == ".." || strings.Trim(name, "/") == "" {
return ""
}
return name
}
func driveDownloadFallbackFileName(title, fileToken string) string {
if name := driveDownloadNormalizeFileName(title); name != "" {
return name
}
return fileToken
}
func driveDownloadCandidateOutputPath(header http.Header, candidate string) (string, bool) {
fileName := driveDownloadNormalizeFileName(candidate)
if fileName == "" {
return "", false
}
fileName = sanitizeExportFileName(fileName, "")
if fileName == "" {
return "", false
}
fileName, _ = common.AutoAppendDownloadExtension(fileName, header, "")
if strings.TrimSpace(fileName) == "" || fileName == "." || fileName == ".." || strings.Trim(fileName, "/") == "" {
return "", false
}
return fileName, true
}
func driveDownloadDefaultOutputPath(header http.Header, title, fileToken string, validatePath driveDownloadOutputPathValidator) (string, error) {
candidates := []string{
larkcore.FileNameByHeader(header),
title,
fileToken,
}
var lastErr error
for _, candidate := range candidates {
fileName, ok := driveDownloadCandidateOutputPath(header, candidate)
if !ok {
continue
}
if validatePath != nil {
if err := validatePath(fileName); err != nil {
lastErr = err
continue
}
}
return fileName, nil
}
if lastErr != nil {
return "", lastErr
}
return fileToken, nil
}
func driveDownloadShouldFailOnMetadataTitleError(ctx context.Context, err error) bool {
if ctx != nil {
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
return true
}
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
if problem, ok := errs.ProblemOf(err); ok {
if problem.Category == errs.CategoryAuthorization {
return true
}
}
return false
}
var DriveDownload = common.Shortcut{
Service: "drive",
Command: "+download",
Description: "Download a file from Drive to local",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
// Metadata is only required when --output is omitted and the CLI needs the
// remote title as the pre-download fallback filename.
ConditionalScopes: []string{driveMetadataReadScope},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file token", Required: true},
{Name: "output", Desc: "local save path"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
outputPath := runtime.Str("output")
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if outputPath == "" {
if err := runtime.EnsureScopes([]string{driveMetadataReadScope}); err != nil {
return err
}
return nil
}
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
outputPath := runtime.Str("output")
plan := common.NewDryRunAPI()
downloadDesc := "[1] Download file bytes to the explicit output path"
if outputPath == "" {
outputPath = fileToken
outputPath = "<Content-Disposition filename | metadata title | token>"
downloadDesc = "[2] Download file bytes; Content-Disposition filename wins over metadata title when present"
plan.
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[1] Resolve metadata title before downloading; fails before the download request if metadata scope is missing").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
})
}
return common.NewDryRunAPI().
return plan.
GET("/open-apis/drive/v1/files/:file_token/download").
Set("file_token", fileToken).Set("output", outputPath)
Desc(downloadDesc).
Set("file_token", fileToken).
Set("output", outputPath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
outputPath := runtime.Str("output")
overwrite := runtime.Bool("overwrite")
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if outputPath == "" {
outputPath = fileToken
}
// Early path validation + overwrite check
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
if outputPath != "" {
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
}
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
var metadataTitle string
if outputPath == "" {
title, err := common.FetchDriveMetaTitle(runtime, fileToken, "file")
if err != nil {
if driveDownloadShouldFailOnMetadataTitleError(ctx, err) {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "warning: metadata title lookup failed; continuing with Content-Disposition or token filename: %v\n", err)
} else {
metadataTitle = title
}
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
@@ -70,6 +206,20 @@ var DriveDownload = common.Shortcut{
}
defer resp.Body.Close()
if outputPath == "" {
var resolveErr error
outputPath, resolveErr = driveDownloadDefaultOutputPath(resp.Header, metadataTitle, fileToken, func(path string) error {
_, err := runtime.ResolveSavePath(path)
return err
})
if resolveErr != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "cannot derive a safe default output path: %s", resolveErr).WithCause(resolveErr)
}
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,

View File

@@ -639,12 +639,29 @@ func sanitizeExportFileName(name, fallback string) string {
)
name = replacer.Replace(name)
name = strings.Trim(name, ". ")
if name == "" {
if name == "" || isWindowsReservedDeviceFileName(name) {
return fallback
}
return name
}
func isWindowsReservedDeviceFileName(name string) bool {
base := strings.TrimRight(name, ". ")
if dot := strings.IndexByte(base, '.'); dot >= 0 {
base = base[:dot]
}
switch strings.ToUpper(base) {
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
return true
}
if len(base) == 4 {
prefix := strings.ToUpper(base[:3])
suffix := base[3]
return (prefix == "COM" || prefix == "LPT") && suffix >= '1' && suffix <= '9'
}
return false
}
// ensureExportFileExtension appends the expected local suffix when the chosen
// file name does not already end with the export format's extension.
func ensureExportFileExtension(name, fileExtension string) string {

View File

@@ -58,6 +58,20 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" {
t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf")
}
for _, name := range []string{"CON.txt", "con.backup.txt", "nul", "COM1.pdf", "lpt9.csv"} {
t.Run("reserved-"+name, func(t *testing.T) {
if got := sanitizeExportFileName(name, "fallback.bin"); got != "fallback.bin" {
t.Fatalf("sanitizeExportFileName(%q) = %q, want fallback.bin", name, got)
}
})
}
for _, name := range []string{"CONTEXT.txt", "COM10.pdf", "LPT0.csv"} {
t.Run("allowed-"+name, func(t *testing.T) {
if got := sanitizeExportFileName(name, "fallback.bin"); got != name {
t.Fatalf("sanitizeExportFileName(%q) = %q, want original name", name, got)
}
})
}
if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" {
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md")
}

View File

@@ -12,19 +12,31 @@ import (
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn driveRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
var driveTaskCheckPollMu sync.Mutex
func driveTestConfig() *core.CliConfig {
@@ -34,9 +46,15 @@ func driveTestConfig() *core.CliConfig {
}
func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return mountAndRunDriveWithContext(t, context.Background(), s, args, f, stdout)
}
func mountAndRunDriveWithContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "drive"}
s.Mount(parent, f)
parent.SetContext(ctx)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
@@ -1562,6 +1580,613 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
}
}
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="////"`},
"Content-Type": []string{"application/octet-stream"},
}
if got := mustDriveDownloadDefaultOutputPath(t, header, "////", "file_token", nil); got != "file_token" {
t.Fatalf("default output path = %q, want file_token", got)
}
if got := driveDownloadFallbackFileName(`\\`, "file_token"); got != "file_token" {
t.Fatalf("fallback filename = %q, want file_token", got)
}
}
func TestDriveDownloadDefaultOutputPathSanitizesWindowsReservedCharacters(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="Q1: forecast?.txt"`},
"Content-Type": []string{"text/plain"},
}
if got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", nil); got != "Q1_ forecast_.txt" {
t.Fatalf("default output path = %q, want Q1_ forecast_.txt", got)
}
header = http.Header{
"Content-Type": []string{"text/plain; charset=utf-8"},
}
if got := mustDriveDownloadDefaultOutputPath(t, header, "Q1: forecast?", "file_token", nil); got != "Q1_ forecast_.txt" {
t.Fatalf("metadata fallback output path = %q, want Q1_ forecast_.txt", got)
}
}
func TestDriveDownloadDefaultOutputPathRejectsWindowsReservedDeviceNames(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="CON.txt"`},
"Content-Type": []string{"text/plain"},
}
if got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", nil); got != "Metadata Title.txt" {
t.Fatalf("default output path = %q, want Metadata Title.txt", got)
}
header = http.Header{
"Content-Type": []string{"application/octet-stream"},
}
if got := mustDriveDownloadDefaultOutputPath(t, header, "COM1.pdf", "file_token", nil); got != "file_token" {
t.Fatalf("metadata fallback output path = %q, want file_token", got)
}
}
func TestDriveDownloadDefaultOutputPathFallsBackWhenHeaderCandidateFailsPathValidation(t *testing.T) {
validatePath := func(path string) error {
_, err := validate.SafeOutputPath(path)
return err
}
header := http.Header{
"Content-Disposition": []string{"attachment; filename=\"evil\u202etxt\""},
"Content-Type": []string{"text/plain"},
}
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
got := mustDriveDownloadDefaultOutputPath(t, header, "Metadata Title", "file_token", validatePath)
if got != "Metadata Title.txt" {
t.Fatalf("default output path = %q, want Metadata Title.txt", got)
}
header = http.Header{
"Content-Type": []string{"text/plain"},
}
got = mustDriveDownloadDefaultOutputPath(t, header, "evil\u202etxt", "file_token", validatePath)
if got != "file_token.txt" {
t.Fatalf("metadata fallback output path = %q, want file_token.txt", got)
}
}
func mustDriveDownloadDefaultOutputPath(t *testing.T, header http.Header, title, fileToken string, validatePath driveDownloadOutputPathValidator) string {
t.Helper()
got, err := driveDownloadDefaultOutputPath(header, title, fileToken, validatePath)
if err != nil {
t.Fatalf("driveDownloadDefaultOutputPath() error = %v", err)
}
return got
}
func TestDriveDownloadDryRunPlansMetadataWhenOutputOmitted(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_dryrun",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
apis, _ := data["api"].([]interface{})
if len(apis) != 2 {
t.Fatalf("api count = %d, want 2\nstdout=%s", len(apis), stdout.String())
}
first, _ := apis[0].(map[string]interface{})
if first["method"] != "POST" || first["url"] != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("first api = %#v, want metadata batch_query", first)
}
second, _ := apis[1].(map[string]interface{})
if second["method"] != "GET" || second["url"] != "/open-apis/drive/v1/files/file_dryrun/download" {
t.Fatalf("second api = %#v, want file download", second)
}
if second["desc"] != "[2] Download file bytes; Content-Disposition filename wins over metadata title when present" {
t.Fatalf("second desc = %#v, want metadata-aware step 2", second["desc"])
}
}
func TestDriveDownloadDryRunExplicitOutputSkipsMetadata(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_dryrun",
"--output", "report.bin",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
apis, _ := data["api"].([]interface{})
if len(apis) != 1 {
t.Fatalf("api count = %d, want 1\nstdout=%s", len(apis), stdout.String())
}
first, _ := apis[0].(map[string]interface{})
if first["method"] != "GET" || first["url"] != "/open-apis/drive/v1/files/file_dryrun/download" {
t.Fatalf("api = %#v, want file download", first)
}
if first["desc"] != "[1] Download file bytes to the explicit output path" {
t.Fatalf("api desc = %#v, want explicit-output step 1", first["desc"])
}
if data["output"] != "report.bin" {
t.Fatalf("output = %#v, want report.bin", data["output"])
}
}
func TestDriveDownloadOmittedOutputRequiresMetadataScope(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:file:download"}, nil)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_no_scope",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected missing metadata scope error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeMissingScope {
t.Fatalf("problem = category %q subtype %q, want authorization/missing_scope", problem.Category, problem.Subtype)
}
}
func TestDriveDownloadRejectsInvalidFileToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "../bad",
"--output", "report.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected invalid file-token error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file-token" {
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--file-token", problem.Category, problem.Subtype, validationErr.Param)
}
}
func TestDriveDownloadRejectsUnsafeExplicitOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_safe",
"--output", "../report.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected unsafe output error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--output" {
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--output", problem.Category, problem.Subtype, validationErr.Param)
}
}
func TestDriveDownloadExplicitOutputSkipsMetadataScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:file:download"}, nil)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_no_meta_scope/download",
Status: 200,
RawBody: []byte("bytes"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_no_meta_scope",
"--output", "explicit.bin",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data, err := os.ReadFile(filepath.Join(tmpDir, "explicit.bin")); err != nil || string(data) != "bytes" {
t.Fatalf("explicit output content = %q, err=%v; want bytes", string(data), err)
}
}
func TestDriveDownloadRejectsExistingDefaultOutputWithoutOverwrite(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_existing_title", "doc_type": "file", "title": "Existing Report"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_existing_title/download",
Status: 200,
RawBody: []byte("new"),
Headers: http.Header{"Content-Type": []string{"text/plain"}},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "Existing Report.txt"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_existing_title",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected overwrite protection error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--output" {
t.Fatalf("problem = category %q subtype %q param %q, want validation/invalid_argument/--output", problem.Category, problem.Subtype, validationErr.Param)
}
}
func TestDriveDownloadUsesContentDispositionWhenOutputOmitted(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
metaStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_named", "doc_type": "file", "title": "Metadata Report"},
},
},
},
}
reg.Register(metaStub)
metadataSeenBeforeDownload := false
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_named/download",
Status: 200,
RawBody: []byte("downloaded"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename="server-report.md"`},
},
OnMatch: func(req *http.Request) {
metadataSeenBeforeDownload = len(metaStub.CapturedBody) > 0
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_named",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !metadataSeenBeforeDownload {
t.Fatal("metadata title lookup must happen before download")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "server-report.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "downloaded" {
t.Fatalf("downloaded content = %q, want downloaded", string(data))
}
out := decodeDriveEnvelope(t, stdout)
if got := filepath.Base(common.GetString(out, "saved_path")); got != "server-report.md" {
t.Fatalf("saved_path base=%q, want server-report.md\nstdout=%s", got, stdout.String())
}
}
func TestDriveDownloadFallsBackToMetadataTitleWhenOutputOmitted(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_title", "doc_type": "file", "title": "Quarterly Report"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_title/download",
Status: 200,
RawBody: []byte("plain text"),
Headers: http.Header{
"Content-Type": []string{"text/plain; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_title",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "Quarterly Report.txt"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "plain text" {
t.Fatalf("downloaded content = %q, want plain text", string(data))
}
out := decodeDriveEnvelope(t, stdout)
if got := filepath.Base(common.GetString(out, "saved_path")); got != "Quarterly Report.txt" {
t.Fatalf("saved_path base=%q, want Quarterly Report.txt\nstdout=%s", got, stdout.String())
}
}
func TestDriveDownloadFallsBackToTokenWhenOutputOmittedAndMetadataEmpty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_empty/download",
Status: 200,
RawBody: []byte("bytes"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_empty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "file_empty"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "bytes" {
t.Fatalf("downloaded content = %q, want bytes", string(data))
}
}
func TestDriveDownloadMetadataNonPermissionErrorContinuesWithTokenFallback(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99991400,
"msg": "rate limit",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_rate_limited/download",
Status: 200,
RawBody: []byte("bytes"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_rate_limited",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "warning: metadata title lookup failed") {
t.Fatalf("stderr missing metadata warning: %s", stderr.String())
}
data, err := os.ReadFile(filepath.Join(tmpDir, "file_rate_limited"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "bytes" {
t.Fatalf("downloaded content = %q, want bytes", string(data))
}
out := decodeDriveEnvelope(t, stdout)
if got := filepath.Base(common.GetString(out, "saved_path")); got != "file_rate_limited" {
t.Fatalf("saved_path base=%q, want file_rate_limited\nstdout=%s", got, stdout.String())
}
}
func TestDriveDownloadTypedMetadataTimeoutFallsBack(t *testing.T) {
err := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "metadata lookup timed out")
if driveDownloadShouldFailOnMetadataTitleError(context.Background(), err) {
t.Fatal("typed metadata timeout should use warning fallback")
}
}
func TestDriveDownloadMetadataContextErrorStopsBeforeDownload(t *testing.T) {
for _, tc := range []struct {
name string
wantErr error
makeCtx func() (context.Context, context.CancelFunc)
cancelIn func(context.CancelFunc, *http.Request)
}{
{
name: "canceled",
wantErr: context.Canceled,
makeCtx: func() (context.Context, context.CancelFunc) {
return context.WithCancel(context.Background())
},
cancelIn: func(cancel context.CancelFunc, req *http.Request) {
cancel()
<-req.Context().Done()
},
},
{
name: "deadline",
wantErr: context.DeadlineExceeded,
makeCtx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 20*time.Millisecond)
},
cancelIn: func(_ context.CancelFunc, req *http.Request) {
<-req.Context().Done()
},
},
} {
t.Run(tc.name, func(t *testing.T) {
runCtx, cancel := tc.makeCtx()
defer cancel()
cfg := driveTestConfig()
f, _, _, _ := cmdutil.TestFactory(t, cfg)
metadataRequests := 0
downloadRequests := 0
f.LarkClient = func() (*lark.Client, error) {
return lark.NewClient(
cfg.AppID,
credential.RuntimeAppSecret(cfg.AppSecret),
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(cfg.Brand)),
lark.WithHttpClient(&http.Client{Transport: driveRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/metas/batch_query") {
metadataRequests++
tc.cancelIn(cancel, req)
return nil, req.Context().Err()
}
if strings.Contains(req.URL.Path, "/download") {
downloadRequests++
}
return nil, errors.New("unexpected request after metadata context error")
})}),
), nil
}
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDriveWithContext(t, runCtx, DriveDownload, []string{
"+download",
"--file-token", "file_context_error",
"--as", "bot",
}, f, nil)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("error = %v, want %v", err, tc.wantErr)
}
if metadataRequests != 1 {
t.Fatalf("metadata requests = %d, want 1", metadataRequests)
}
if downloadRequests != 0 {
t.Fatalf("download requests = %d, want 0", downloadRequests)
}
})
}
}
func TestDriveDownloadMetadataErrorBeforeDownloadWhenOutputOmitted(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99991679,
"msg": "missing scope",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_no_meta",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected metadata lookup error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeMissingScope || problem.Code != 99991679 {
t.Fatalf("problem = category %q subtype %q code %d, want authorization/missing_scope/99991679", problem.Category, problem.Subtype, problem.Code)
}
}
type capturedDriveMultipart struct {
Fields map[string]string
Files map[string][]byte

View File

@@ -348,7 +348,7 @@ func driveListCommentsScopeParam(scope string) (*bool, bool) {
}
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
items := common.GetSlice(data, "items")
items := driveCommentItems(data)
return map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,

View File

@@ -525,3 +525,38 @@ func TestDriveListCommentsExecuteAppsPageURL(t *testing.T) {
t.Fatalf("count = %#v, want 1", got)
}
}
func TestDriveListCommentsOmittedItemsNormalized(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": ""},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
items, ok := data["items"].([]interface{})
if !ok {
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveListRepliesOp = driveCommentOp{
Label: "replies list",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveListRepliesSpec struct {
Ref driveCommentRef
CommentID string
PageSize int
PageToken string
NeedReaction bool
}
// DriveListReplies lists the replies of one comment through the Drive comment
// reply list API, while accepting Wiki URLs/tokens and resolving them to the
// underlying object.
var DriveListReplies = common.Shortcut{
Service: "drive",
Command: "+list-replies",
Description: "List replies of a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveListRepliesOp),
common.Flag{Name: "comment-id", Desc: "comment ID whose replies to list (from drive +list-comments)", Required: true},
common.Flag{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
common.Flag{Name: "page-token", Desc: "pagination token from previous response"},
common.Flag{Name: "need-reaction", Type: "bool", Desc: "include reaction data on replies"},
),
Tips: []string{
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"The root reply (the comment body itself) is the earliest-created reply: it is items[0] of the FIRST page only (no --page-token); items[0] of later pages is a regular reply.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveListRepliesSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveListRepliesSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveListRepliesDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListRepliesSpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveListRepliesOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing replies of comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
data, err := runtime.CallAPITyped(
"GET",
path,
buildDriveListRepliesParams(spec, target.FileType),
nil,
)
if err != nil {
return err
}
items := driveCommentItems(data)
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"items": items,
"has_more": common.GetBool(data, "has_more"),
"page_token": common.GetString(data, "page_token"),
"count": len(items),
}), nil)
return nil
},
}
func readDriveListRepliesSpec(runtime *common.RuntimeContext) (driveListRepliesSpec, error) {
ref, err := resolveDriveCommentInput(driveListRepliesOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveListRepliesSpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveListRepliesSpec{}, err
}
pageSize := runtime.Int("page-size")
if pageSize < 1 || pageSize > 100 {
return driveListRepliesSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
}
return driveListRepliesSpec{
Ref: ref,
CommentID: commentID,
PageSize: pageSize,
PageToken: strings.TrimSpace(runtime.Str("page-token")),
NeedReaction: runtime.Bool("need-reaction"),
}, nil
}
func buildDriveListRepliesParams(spec driveListRepliesSpec, fileType string) map[string]interface{} {
params := map[string]interface{}{
"file_type": fileType,
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
if spec.NeedReaction {
params["need_reaction"] = true
}
return params
}
func buildDriveListRepliesDryRun(spec driveListRepliesSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> list comment replies").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
Desc("[2] List replies of comment on resolved document").
Params(buildDriveListRepliesParams(spec, "<obj_type from step 1>")).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc("1-step request: list comment replies").
GET("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
Params(buildDriveListRepliesParams(spec, spec.Ref.Type)).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveListRepliesExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
if got := query.Get("page_size"); got != "50" {
t.Errorf("page_size = %q, want 50 (default)", got)
}
if query.Has("page_token") {
t.Errorf("page_token should be omitted when not set, got %q", query.Get("page_token"))
}
if query.Has("need_reaction") {
t.Errorf("need_reaction should be omitted when not set, got %q", query.Get("need_reaction"))
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"reply_id": "reply_1",
"content": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"type": "text_run",
"text_run": map[string]interface{}{"text": "根回复正文"},
},
},
},
},
map[string]interface{}{"reply_id": "reply_2"},
},
"has_more": true,
"page_token": "next_page",
},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
items := mustSliceValue(t, data["items"], "data.items")
if len(items) != 2 {
t.Fatalf("len(items) = %d, want 2", len(items))
}
firstItem := mustMapValue(t, items[0], "data.items[0]")
if got := mustStringField(t, firstItem, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
}
firstContent := mustMapValue(t, firstItem["content"], "data.items[0].content")
firstElements := mustSliceValue(t, firstContent["elements"], "data.items[0].content.elements")
firstElement := mustMapValue(t, firstElements[0], "data.items[0].content.elements[0]")
firstText := mustMapValue(t, firstElement["text_run"], "data.items[0].content.elements[0].text_run")
if got := mustStringField(t, firstText, "text", "data.items[0].content.elements[0].text_run.text"); got != "根回复正文" {
t.Fatalf("items[0] text = %q, want 根回复正文", got)
}
secondItem := mustMapValue(t, items[1], "data.items[1]")
if got := mustStringField(t, secondItem, "reply_id", "data.items[1].reply_id"); got != "reply_2" {
t.Fatalf("items[1].reply_id = %q, want reply_2", got)
}
if got := data["count"]; got != float64(2) {
t.Fatalf("count = %#v, want 2", got)
}
if got := data["has_more"]; got != true {
t.Fatalf("has_more = %#v, want true", got)
}
if got := mustStringField(t, data, "page_token", "data.page_token"); got != "next_page" {
t.Fatalf("page_token = %q, want next_page", got)
}
}
func TestDriveListRepliesExecuteViaWikiToBitable(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bitableFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/bitableFromWiki/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"reply_id": "reply_1"}},
},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("file_type = %q, want bitable", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
items := mustSliceValue(t, data["items"], "data.items")
item := mustMapValue(t, items[0], "data.items[0]")
if got := mustStringField(t, item, "reply_id", "data.items[0].reply_id"); got != "reply_1" {
t.Fatalf("items[0].reply_id = %q, want reply_1", got)
}
}
func TestDriveListRepliesPaginationAndReactionParams(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("page_size"); got != "10" {
t.Errorf("page_size = %q, want 10", got)
}
if got := query.Get("page_token"); got != "cursor_1" {
t.Errorf("page_token = %q, want cursor_1", got)
}
if got := query.Get("need_reaction"); got != "true" {
t.Errorf("need_reaction = %q, want true", got)
}
if got := query.Get("user_id_type"); got != "" {
t.Errorf("user_id_type = %q, want omitted (flag removed)", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"items": []interface{}{}},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--page-size", "10",
"--page-token", "cursor_1",
"--need-reaction",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}
func TestDriveListRepliesValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe comment id",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "empty comment id",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", " ",
},
wantErr: "--comment-id must not be empty",
wantParam: "--comment-id",
},
{
name: "page size too small",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--page-size", "0",
},
wantErr: "--page-size must be between 1 and 100",
wantParam: "--page-size",
},
{
name: "page size too large",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--page-size", "101",
},
wantErr: "--page-size must be between 1 and 100",
wantParam: "--page-size",
},
{
name: "unsupported url type",
args: []string{
"+list-replies",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
},
wantErr: "replies list supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveListReplies, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveListRepliesPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 1069301,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveListRepliesWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx"},
},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveListRepliesDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--need-reaction",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "GET" {
t.Fatalf("api[0].method = %q, want GET", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
t.Fatalf("api[0].url = %q, want resolved path segments", got)
}
params := mustMapValue(t, call["params"], "api[0].params")
if got := params["need_reaction"]; got != true {
t.Fatalf("api[0].params.need_reaction = %#v, want true", got)
}
}
func TestDriveListRepliesDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "GET" {
t.Fatalf("api[1].method = %q, want GET", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
t.Fatalf("api[1].url = %q, want resolved comment ID", got)
}
}
func TestDriveListRepliesOmittedItemsNormalized(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": ""},
},
})
err := mountAndRunDrive(t, DriveListReplies, []string{
"+list-replies",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
items, ok := data["items"].([]interface{})
if !ok {
t.Fatalf("items must be a JSON array even when the server omits it, got %#v", data["items"])
}
if len(items) != 0 {
t.Fatalf("len(items) = %d, want 0", len(items))
}
if got := data["count"]; got != float64(0) {
t.Fatalf("count = %#v, want 0", got)
}
}

View File

@@ -51,9 +51,10 @@ var driveMemberAddURLPathToType = []struct {
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
{"/page/", "apps"},
}
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps"}
const driveMemberAddBatchLimit = 10
@@ -61,7 +62,7 @@ const driveMemberAddBatchLimit = 10
var DriveMemberAdd = common.Shortcut{
Service: "drive",
Command: "+member-add",
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
Description: "Add a collaborator/member permission to a Drive resource",
Risk: "high-risk-write",
Scopes: []string{"docs:permission.member:create"},
AuthTypes: []string{"user", "bot"},
@@ -320,7 +321,7 @@ func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string
func isSupportedDriveMemberAddResourceType(resourceType string) bool {
switch resourceType {
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes", "apps":
return true
default:
return false

View File

@@ -35,10 +35,12 @@ func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) {
{"folder URL", "https://example.feishu.cn/drive/folder/fldTok", "", "fldTok", "folder"},
{"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
{"mindnotes URL", "https://example.feishu.cn/mindnotes/mndTok", "", "mndTok", "mindnote"},
{"apps page URL", "https://example.feishu.cn/page/appMetaTok/?from=share", "", "appMetaTok", "apps"},
{"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "", "doxTok", "docx"},
{"explicit type overrides URL", "https://example.feishu.cn/docx/doxTok", "wiki", "doxTok", "wiki"},
{"bare token with explicit docx type", "N83ZduEnHooFswxnVWGcazlLnFf", "docx", "N83ZduEnHooFswxnVWGcazlLnFf", "docx"},
{"bare token with explicit folder type", "fldToken123", "folder", "fldToken123", "folder"},
{"bare token with explicit apps type", "appMetaTok", "apps", "appMetaTok", "apps"},
}
for _, temp := range tests {
tt := temp

View File

@@ -0,0 +1,326 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveMemberListSpec struct {
Token string
Type string
Fields string
PermType string
}
var driveMemberListTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder", "apps",
}
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
var driveMemberListPermTypes = []string{"container", "single_page"}
var driveMemberListURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
{"/page/", "apps"},
}
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveMemberListSpec{}, err
}
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
if err != nil {
return driveMemberListSpec{}, err
}
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
if err != nil {
return driveMemberListSpec{}, err
}
return driveMemberListSpec{
Token: token,
Type: resourceType,
Fields: fields,
PermType: permType,
}, nil
}
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
if err != nil {
return "", "", err
}
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.Hostname() == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
}
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
if !ok {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
raw,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return ref.Token, ref.Type, nil
}
if explicitType == "" {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(driveMemberListTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(raw, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return raw, explicitType, nil
}
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
for _, mapping := range driveMemberListURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
token := path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: mapping.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
if changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
return "", nil
}
parts := strings.Split(raw, ",")
fields := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
field := strings.ToLower(strings.TrimSpace(part))
if field == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
if field == "*" {
if len(parts) != 1 {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
}
return "*", nil
}
if !driveMemberListFieldAllowed(field) {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for --fields, allowed: %s, *",
strings.TrimSpace(part),
strings.Join(driveMemberListFields, ", "),
).WithParam("--fields")
}
if !seen[field] {
fields = append(fields, field)
seen[field] = true
}
}
return strings.Join(fields, ","), nil
}
func driveMemberListFieldAllowed(field string) bool {
for _, allowed := range driveMemberListFields {
if field == allowed {
return true
}
}
return false
}
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
if err != nil {
return "", err
}
if resourceType != "wiki" && changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
}
return permType, nil
}
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
for _, candidate := range allowed {
if strings.EqualFold(value, candidate) {
return candidate, nil
}
}
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for %s, allowed: %s",
value,
flagName,
strings.Join(allowed, ", "),
).WithParam(flagName)
}
func (s driveMemberListSpec) apiPath() string {
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
}
func (s driveMemberListSpec) params() map[string]interface{} {
params := map[string]interface{}{"type": s.Type}
if s.Fields != "" {
params["fields"] = s.Fields
}
if s.PermType != "" {
params["perm_type"] = s.PermType
}
return params
}
// DriveMemberList lists collaborator/member permissions on a Drive resource.
var DriveMemberList = common.Shortcut{
Service: "drive",
Command: "+member-list",
Description: "List collaborator/member permissions on a Drive resource",
Risk: "read",
Scopes: []string{"docs:permission.member:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder/apps)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders.",
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
"--perm-type only applies to wiki nodes.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveMemberListSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("List Drive collaborator/member permissions").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
if err != nil {
return err
}
if items, ok := data["items"].([]interface{}); ok {
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
}
runtime.OutFormat(data, nil, func(w io.Writer) {
renderDriveMemberListPretty(w, data)
})
return nil
},
}
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
items, _ := data["items"].([]interface{})
if len(items) == 0 {
fmt.Fprintln(w, "No Drive members found.")
return
}
for i, raw := range items {
member, _ := raw.(map[string]interface{})
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
fmt.Fprintf(w, " perm_type: %s\n", permType)
}
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
fmt.Fprintf(w, " type: %s\n", memberType)
}
if name := driveMemberListValue(member["name"]); name != "-" {
fmt.Fprintf(w, " name: %s\n", name)
}
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
fmt.Fprintf(w, " avatar: %s\n", avatar)
}
if label, ok := member["external_label"]; ok {
fmt.Fprintf(w, " external_label: %v\n", label)
}
fmt.Fprintln(w)
}
}
func driveMemberListValue(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}

View File

@@ -0,0 +1,439 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +member-list"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("fields", "", "")
cmd.Flags().String("perm-type", "", "")
for name, value := range map[string]string{
"token": token,
"type": docType,
"fields": fields,
"perm-type": permType,
} {
if value == "" {
continue
}
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
{
name: "apps page URL",
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
wantTok: "appMetaTok",
wantType: "apps",
},
{
name: "bare token with explicit apps type",
token: "appBareMetaTok",
docType: "apps",
wantTok: "appBareMetaTok",
wantType: "apps",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
}
})
}
}
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "folder",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid value",
},
{
name: "invalid fields",
token: "doxTok",
docType: "docx",
fields: "name,unknown",
wantParam: "--fields",
wantMessage: "invalid value",
},
{
name: "star mixed with fields",
token: "doxTok",
docType: "docx",
fields: "*,name",
wantParam: "--fields",
wantMessage: "cannot be combined",
},
{
name: "perm type rejected for non-wiki",
token: "doxTok",
docType: "docx",
permType: "single_page",
wantParam: "--perm-type",
wantMessage: "only applies when resource type is wiki",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
_, err := readDriveMemberListSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDriveMemberListSpecParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
want map[string]interface{}
}{
{
name: "default omits optional params",
token: "doxTok",
docType: "docx",
want: map[string]interface{}{"type": "docx"},
},
{
name: "fields canonicalized and deduplicated",
token: "doxTok",
docType: "docx",
fields: "Name,avatar,name",
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
},
{
name: "wiki accepts perm type",
token: "wikTok",
docType: "WIKI",
fields: "*",
permType: "SINGLE_PAGE",
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("params = %#v, want %#v", got, tt.want)
}
})
}
}
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "https://example.feishu.cn/drive/folder/fldTok",
"--fields", "*",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.Data.API))
}
api := got.Data.API[0]
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
t.Fatalf("api = %#v", api)
}
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
t.Fatalf("params = %#v", api.Params)
}
if _, ok := api.Params["perm_type"]; ok {
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
}
}
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/doxTok/members",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"type": "user",
"name": "zhangsan",
"server_future": "preserved",
"external_label": true,
},
},
"server_top_level": "preserved",
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "doxTok",
"--type", "docx",
"--fields", "name,type,external_label",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(capturedQuery, "type=docx") ||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
t.Fatalf("captured query = %q", capturedQuery)
}
data := decodeDriveEnvelope(t, stdout)
if data["server_top_level"] != "preserved" {
t.Fatalf("server_top_level = %#v", data["server_top_level"])
}
for _, key := range []string{"token", "type", "count"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
}
}
items, _ := data["items"].([]interface{})
if len(items) != 1 {
t.Fatalf("items = %#v, want one item", data["items"])
}
item, _ := items[0].(map[string]interface{})
if item["server_future"] != "preserved" || item["external_label"] != true {
t.Fatalf("item future fields not preserved: %#v", item)
}
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
t.Fatalf("stderr = %q, want count log", stderr.String())
}
}
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
}
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
}
}
func TestDriveMemberListPrettyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/wikTok/members",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"perm_type": "single_page",
"type": "user",
"name": "zhangsan",
},
},
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "wikTok",
"--type", "wiki",
"--perm-type", "single_page",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,291 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type drivePermissionGetSettingSpec struct {
Token string
Type string
}
type drivePermissionGetSettingResourceKind struct {
Type string
CanonicalPath string
PathAliases []string
}
var drivePermissionGetSettingResourceKinds = []drivePermissionGetSettingResourceKind{
{Type: "doc", CanonicalPath: "/doc/"},
{Type: "sheet", CanonicalPath: "/sheets/"},
{Type: "file", CanonicalPath: "/file/"},
{Type: "wiki", CanonicalPath: "/wiki/"},
{Type: "bitable", CanonicalPath: "/base/", PathAliases: []string{"/bitable/"}},
{Type: "docx", CanonicalPath: "/docx/"},
{Type: "mindnote", CanonicalPath: "/mindnote/", PathAliases: []string{"/mindnotes/"}},
{Type: "minutes", CanonicalPath: "/minutes/"},
{Type: "slides", CanonicalPath: "/slides/"},
{Type: "folder", CanonicalPath: "/drive/folder/"},
{Type: "apps", CanonicalPath: "/page/"},
}
var drivePermissionGetSettingTypes = func() []string {
types := make([]string, 0, len(drivePermissionGetSettingResourceKinds))
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
types = append(types, resourceKind.Type)
}
return types
}()
func findDrivePermissionGetSettingResourceKind(docType string) (drivePermissionGetSettingResourceKind, bool) {
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
if docType == resourceKind.Type {
return resourceKind, true
}
}
return drivePermissionGetSettingResourceKind{}, false
}
func parseDrivePermissionGetSettingResourcePath(path, prefix, docType string) (common.ResourceRef, bool) {
if !strings.HasPrefix(path, prefix) {
return common.ResourceRef{}, false
}
escapedToken := strings.TrimSuffix(path[len(prefix):], "/")
if escapedToken == "" || strings.Contains(escapedToken, "/") {
return common.ResourceRef{}, false
}
token, err := url.PathUnescape(escapedToken)
if err != nil || token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: docType, Token: token}, true
}
func parseDrivePermissionGetSettingResourceKindPath(path string, resourceKind drivePermissionGetSettingResourceKind) (common.ResourceRef, bool) {
if ref, ok := parseDrivePermissionGetSettingResourcePath(path, resourceKind.CanonicalPath, resourceKind.Type); ok {
return ref, true
}
for _, alias := range resourceKind.PathAliases {
if ref, ok := parseDrivePermissionGetSettingResourcePath(path, alias, resourceKind.Type); ok {
return ref, true
}
}
return common.ResourceRef{}, false
}
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
rawToken := strings.TrimSpace(runtime.Str("token"))
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
if rawToken == "" {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token is required",
).WithParam("--token")
}
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --type %q: allowed values are %s",
explicitType,
strings.Join(drivePermissionGetSettingTypes, ", "),
).WithParam("--type")
}
if strings.Contains(rawToken, "://") {
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
if !ok {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive resource URL or a bare token with --type",
rawToken,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validateDrivePermissionGetSettingToken(ref.Token); err != nil {
return drivePermissionGetSettingSpec{}, err
}
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
}
if explicitType == "" {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token (allowed: %s)",
strings.Join(drivePermissionGetSettingTypes, ", "),
).WithParam("--type")
}
if err := validateDrivePermissionGetSettingToken(rawToken); err != nil {
return drivePermissionGetSettingSpec{}, err
}
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
}
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return common.ResourceRef{}, false
}
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
if ref, ok := parseDrivePermissionGetSettingResourceKindPath(parsed.EscapedPath(), resourceKind); ok {
return ref, true
}
}
return common.ResourceRef{}, false
}
func drivePermissionGetSettingTypeAllowed(docType string) bool {
_, ok := findDrivePermissionGetSettingResourceKind(docType)
return ok
}
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
resourceKind, ok := findDrivePermissionGetSettingResourceKind(s.Type)
token := strings.TrimSpace(s.Token)
if !ok || token == "" {
return ""
}
brand := core.LarkBrand("")
if runtime != nil && runtime.Config != nil {
brand = runtime.Config.Brand
}
host := "https://www.feishu.cn"
if brand == core.BrandLark {
host = "https://www.larksuite.com"
}
return host + resourceKind.CanonicalPath + url.PathEscape(token)
}
func validateDrivePermissionGetSettingToken(token string) error {
if err := validate.ResourceName(token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
if token == "." || strings.Contains(token, "/") {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token must be a non-dot single path segment",
).WithParam("--token")
}
return nil
}
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
return map[string]interface{}{"type": s.Type}
}
func (s drivePermissionGetSettingSpec) apiPath() string {
return drivePermissionPublicV2Path(s.Token)
}
func drivePermissionPublicV2Path(token string) string {
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
}
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
permissionPublic := common.GetMap(data, "permission_public")
if permissionPublic == nil {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"drive permission get response missing data.permission_public",
)
}
return permissionPublic, nil
}
// DrivePermissionGetSetting queries permission_public settings for a Drive resource.
var DrivePermissionGetSetting = common.Shortcut{
Service: "drive",
Command: "+permission-get-setting",
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
Risk: "read",
Scopes: []string{"docs:permission.setting:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder/apps)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDrivePermissionGetSettingSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Get Drive permission settings").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped(
"GET",
spec.apiPath(),
spec.params(),
nil,
)
if err != nil {
return err
}
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
if err != nil {
return err
}
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
if err != nil {
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"encode drive permission settings for pretty output",
).WithCause(err)
}
out := map[string]interface{}{"permission_public": permissionPublic}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "Type: %s\n", spec.Type)
fmt.Fprintf(w, "Token: %s\n", spec.Token)
if url := spec.url(runtime); url != "" {
fmt.Fprintf(w, "URL: %s\n", url)
}
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
})
return nil
},
}

View File

@@ -0,0 +1,585 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
if token != "" {
if err := cmd.Flags().Set("token", token); err != nil {
t.Fatalf("set --token: %v", err)
}
}
if docType != "" {
if err := cmd.Flags().Set("type", docType); err != nil {
t.Fatalf("set --type: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "file URL",
token: "https://example.feishu.cn/file/boxTok",
wantTok: "boxTok",
wantType: "file",
},
{
name: "wiki URL",
token: "https://example.feishu.cn/wiki/wikTok",
wantTok: "wikTok",
wantType: "wiki",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "canonical mindnote URL",
token: "https://example.feishu.cn/mindnote/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "bare file token",
token: "boxTok",
docType: "file",
wantTok: "boxTok",
wantType: "file",
},
{
name: "bare wiki token",
token: "wikTok",
docType: "wiki",
wantTok: "wikTok",
wantType: "wiki",
},
{
name: "apps page URL",
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
wantTok: "appMetaTok",
wantType: "apps",
},
{
name: "bare token with explicit apps type",
token: "appBareMetaTok",
docType: "apps",
wantTok: "appBareMetaTok",
wantType: "apps",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok {
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
}
if spec.Type != tt.wantType {
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
}
})
}
}
func TestDrivePermissionGetSettingResourceKindsRoundTrip(t *testing.T) {
t.Parallel()
const token = "resourceRoundTripTok"
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
kind := resourceKind
t.Run(kind.Type, func(t *testing.T) {
t.Parallel()
bareRuntime := newDrivePermissionGetSettingRuntime(t, token, kind.Type)
bareSpec, err := readDrivePermissionGetSettingSpec(bareRuntime)
if err != nil {
t.Fatalf("read bare-token spec: %v", err)
}
resourceURL := bareSpec.url(bareRuntime)
if resourceURL == "" {
t.Fatalf("resource URL is empty for allowed type %q", kind.Type)
}
urlRuntime := newDrivePermissionGetSettingRuntime(t, resourceURL, "")
urlSpec, err := readDrivePermissionGetSettingSpec(urlRuntime)
if err != nil {
t.Fatalf("read generated URL spec %q: %v", resourceURL, err)
}
if urlSpec.Token != token || urlSpec.Type != kind.Type {
t.Fatalf(
"generated URL resolved to token/type %q/%q, want %q/%q",
urlSpec.Token,
urlSpec.Type,
token,
kind.Type,
)
}
})
}
}
func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, "appMetaTok", "apps")
runtime.Config.Brand = core.BrandLark
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want {
t.Fatalf("resource URL = %q, want %q", got, want)
}
}
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "bare token contains path separator",
token: "doxTok/other",
docType: "docx",
wantParam: "--token",
wantMessage: "single path segment",
},
{
name: "bare dot token",
token: ".",
docType: "docx",
wantParam: "--token",
wantMessage: "non-dot single path segment",
},
{
name: "non-HTTP URL",
token: "ftp://example.feishu.cn/docx/doxTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "sheet",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid --type",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
_, err := readDrivePermissionGetSettingSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr, ok := err.(*errs.ValidationError); ok {
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
} else {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantURL string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok",
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
wantType: "folder",
},
{
name: "bare folder token",
token: "fldTok",
docType: "folder",
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
wantType: "docx",
},
{
name: "bare wiki token",
token: "wikTok",
docType: "wiki",
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
wantType: "wiki",
},
{
name: "file URL",
token: "https://example.feishu.cn/file/boxTok",
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
wantType: "file",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
wantType: "minutes",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
wantType: "mindnote",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
out := string(data)
for _, want := range []string{
`"` + tt.wantURL + `"`,
`"GET"`,
`"type":"` + tt.wantType + `"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"folder_token"`) {
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
}
})
}
}
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
for _, key := range []string{"type", "token", "url"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
}
}
permissionPublic, _ := data["permission_public"].(map[string]interface{})
if permissionPublic == nil {
t.Fatalf("permission_public missing in output: %#v", data)
}
for key, want := range map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_field": "preserved",
} {
if permissionPublic[key] != want {
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
}
}
}
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{"unexpected": "response"},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected invalid response error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
}
}
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"server_future_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
"Permission settings:",
`"link_share_entity": "closed"`,
`"server_future_field": "preserved"`,
} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
}
}
}
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesResourceURL(t *testing.T) {
tests := []struct {
name string
token string
docType string
wantURL string
}{
{
name: "apps",
token: "appMetaTok",
docType: "apps",
wantURL: "https://www.feishu.cn/page/appMetaTok",
},
{
name: "minutes",
token: "obcnMinuteTok",
docType: "minutes",
wantURL: "https://www.feishu.cn/minutes/obcnMinuteTok",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/" + tt.token + "/public?type=" + tt.docType,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", tt.token,
"--type", tt.docType,
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "URL: "+tt.wantURL) {
t.Fatalf("pretty output missing resource URL %q:\n%s", tt.wantURL, stdout.String())
}
})
}
}
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
}
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
}
for _, flag := range DrivePermissionGetSetting.Flags {
if flag.Name == "token" && !flag.Required {
t.Fatal("--token must be declared required")
}
}
}

View File

@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveReactReplyOp = driveCommentOp{
Label: "reply reaction",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
const (
driveReactReplyActionAdd = "add"
driveReactReplyActionDelete = "delete"
)
// driveReactReplyReactionTypes mirrors the reaction_type enum from the
// platform metadata (file.comment.reply.reactions.update_reaction). The
// server does NOT validate this field — an arbitrary string is accepted and
// persisted as a broken reaction on the reply, so this local check is the
// only guard. Values are case-sensitive.
var driveReactReplyReactionTypes = map[string]struct{}{
"ANGRY": {}, "APPLAUSE": {}, "ATTENTION": {}, "AWESOME": {}, "BEAR": {}, "BEER": {},
"BETRAYED": {}, "BIGKISS": {}, "BLACKFACE": {}, "BLUBBER": {}, "BLUSH": {}, "BOMB": {},
"CAKE": {}, "CHUCKLE": {}, "CLAP": {}, "CLEAVER": {}, "COMFORT": {}, "CRAZY": {}, "CRY": {},
"CUCUMBER": {}, "DETERGENT": {}, "DIZZY": {}, "DONE": {}, "DONNOTGO": {}, "DROOL": {},
"DROWSY": {}, "DULL": {}, "DULLSTARE": {}, "EATING": {}, "EMBARRASSED": {}, "ENOUGH": {},
"ERROR": {}, "EYESCLOSED": {}, "FACEPALM": {}, "FINGERHEART": {}, "FISTBUMP": {},
"FOLLOWME": {}, "FROWN": {}, "GIFT": {}, "GLANCE": {}, "GOODJOB": {}, "HAMMER": {},
"HAUGHTY": {}, "HEADSET": {}, "HEART": {}, "HEARTBROKEN": {}, "HIGHFIVE": {}, "HUG": {},
"HUSKY": {}, "INNOCENTSMILE": {}, "JIAYI": {}, "JOYFUL": {}, "KISS": {}, "LAUGH": {},
"LIPS": {}, "LOL": {}, "LOOKDOWN": {}, "LOVE": {}, "MONEY": {}, "MUSCLE": {},
"NOSEPICK": {}, "OBSESSED": {}, "OK": {}, "PARTY": {}, "PETRIFIED": {}, "POOP": {},
"PRAISE": {}, "PROUD": {}, "PUKE": {}, "RAINBOWPUKE": {}, "ROSE": {}, "SALUTE": {},
"SCOWL": {}, "SHAKE": {}, "SHHH": {}, "SHOCKED": {}, "SHOWOFF": {}, "SHY": {}, "SICK": {},
"SILENT": {}, "SKULL": {}, "SLAP": {}, "SLEEP": {}, "SLIGHT": {}, "SMART": {}, "SMILE": {},
"SMIRK": {}, "SMOOCH": {}, "SMUG": {}, "SOB": {}, "SPEECHLESS": {}, "SPITBLOOD": {},
"STRIVE": {}, "SWEAT": {}, "TEARS": {}, "TEASE": {}, "TERROR": {}, "THANKS": {},
"THINKING": {}, "THUMBSUP": {}, "TOASTED": {}, "TONGUE": {}, "TRICK": {}, "UPPERLEFT": {},
"WAIL": {}, "WAVE": {}, "WELLDONE": {}, "WHAT": {}, "WHIMPER": {}, "WINK": {}, "WITTY": {},
"WOW": {}, "WRONGED": {}, "XBLUSH": {}, "YAWN": {}, "YEAH": {}, "FIREWORKS": {}, "BULL": {},
"CALF": {}, "AWESOMEN": {}, "2021": {}, "CANDIEDHAWS": {}, "REDPACKET": {}, "FORTUNE": {},
"LUCK": {}, "FIRECRACKER": {}, "Yes": {}, "No": {}, "Get": {}, "LGTM": {}, "Lemon": {},
"EatingFood": {}, "Hundred": {}, "MinusOne": {}, "ThumbsDown": {}, "Fire": {}, "OKR": {},
"Drumstick": {}, "BubbleTea": {}, "Loudspeaker": {}, "Pin": {}, "Coffee": {}, "Alarm": {},
"Trophy": {}, "Music": {}, "Typing": {}, "Pepper": {}, "CheckMark": {}, "CrossMark": {},
}
type driveReactReplySpec struct {
Ref driveCommentRef
ReplyID string
ReactionType string
Action string
}
func (s driveReactReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"action": s.Action,
"reaction_type": s.ReactionType,
"reply_id": s.ReplyID,
}
}
// DriveReactReply adds or removes an emoji reaction on a comment reply
// through the Drive comment reaction API (POST /drive/v2/files/:file_token/
// comments/reaction), while accepting Wiki URLs/tokens and resolving them to
// the underlying object.
var DriveReactReply = common.Shortcut{
Service: "drive",
Command: "+react-reply",
Description: "Add or remove an emoji reaction on a comment reply for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveReactReplyOp),
common.Flag{Name: "reply-id", Desc: "reply ID to react to (from drive +list-replies); the root reply carries the comment body", Required: true},
common.Flag{Name: "emoji", Desc: "reaction_type value, case-sensitive, e.g. THUMBSUP, HEART, DONE, OK", Required: true},
common.Flag{Name: "action", Desc: "add attaches the reaction; delete removes the current identity's reaction", Required: true, Enum: []string{driveReactReplyActionAdd, driveReactReplyActionDelete}},
),
Tips: []string{
"Reply IDs come from `drive +list-replies` (items[].reply_id); reacting to the root reply reacts to the comment itself.",
"--emoji is case-sensitive and validated locally against the platform reaction_type list (the server accepts and persists arbitrary strings as broken reactions); the full list is in the lark-drive reactions guide.",
"Read reactions back via --need-reaction on `drive +list-replies` / `drive +batch-query-comments`; entries with count=0 are leftovers of removed reactions — filter by count>0.",
"add and delete are idempotent: re-adding an existing reaction or deleting an absent one succeeds without change. delete only cancels the current identity's reaction.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveReactReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveReactReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveReactReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveReactReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveReactReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Reaction %s (%s) on reply %s in %s...\n", spec.Action, spec.ReactionType, spec.ReplyID, common.MaskToken(target.FileToken))
path := fmt.Sprintf("/open-apis/drive/v2/files/%s/comments/reaction", validate.EncodePathSegment(target.FileToken))
if _, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"reply_id": spec.ReplyID,
"reaction_type": spec.ReactionType,
"action": spec.Action,
"updated": true,
}), nil)
return nil
},
}
func readDriveReactReplySpec(runtime *common.RuntimeContext) (driveReactReplySpec, error) {
ref, err := resolveDriveCommentInput(driveReactReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveReactReplySpec{}, err
}
replyID := strings.TrimSpace(runtime.Str("reply-id"))
if replyID == "" {
return driveReactReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reply-id must not be empty").WithParam("--reply-id")
}
reactionType, err := parseDriveReactReplyEmoji(runtime.Str("emoji"))
if err != nil {
return driveReactReplySpec{}, err
}
action, err := parseDriveReactReplyAction(runtime.Str("action"))
if err != nil {
return driveReactReplySpec{}, err
}
return driveReactReplySpec{
Ref: ref,
ReplyID: replyID,
ReactionType: reactionType,
Action: action,
}, nil
}
// parseDriveReactReplyEmoji validates the reaction_type against the platform
// enum. Case matters: the wire values mix all-caps and CamelCase (THUMBSUP vs
// ThumbsDown), and the server persists any unknown string as a broken
// reaction instead of rejecting it.
func parseDriveReactReplyEmoji(raw string) (string, error) {
emoji := strings.TrimSpace(raw)
if emoji == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji must not be empty").WithParam("--emoji")
}
if _, ok := driveReactReplyReactionTypes[emoji]; !ok {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unknown --emoji %q; reaction_type values are case-sensitive (e.g. THUMBSUP, HEART, DONE, OK) — see the lark-drive reactions guide for the full list",
emoji,
).WithParam("--emoji")
}
return emoji, nil
}
// parseDriveReactReplyAction normalizes and validates the --action value.
// The flag's Enum already rejects unknown values from the CLI, so the error
// branch only guards direct callers.
func parseDriveReactReplyAction(raw string) (string, error) {
action := strings.ToLower(strings.TrimSpace(raw))
if action != driveReactReplyActionAdd && action != driveReactReplyActionDelete {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --action %q; allowed: %s, %s", raw, driveReactReplyActionAdd, driveReactReplyActionDelete).WithParam("--action")
}
return action, nil
}
func buildDriveReactReplyDryRun(spec driveReactReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> update reply reaction").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v2/files/<obj_token from step 1>/comments/reaction").
Desc("[2] Add or remove the reaction on the resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("reply_id", spec.ReplyID)
}
return common.NewDryRunAPI().
Desc("1-step request: update reply reaction").
POST("/open-apis/drive/v2/files/:file_token/comments/reaction").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("reply_id", spec.ReplyID)
}

View File

@@ -0,0 +1,386 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveReactReplyExecuteDocxAdd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
"--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("failed to decode captured request body: %v", err)
}
if got := mustStringField(t, body, "action", "request.action"); got != "add" {
t.Fatalf("request action = %q, want add", got)
}
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "THUMBSUP" {
t.Fatalf("request reaction_type = %q, want THUMBSUP", got)
}
if got := mustStringField(t, body, "reply_id", "request.reply_id"); got != "reply_1" {
t.Fatalf("request reply_id = %q, want reply_1", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_1" {
t.Fatalf("reply_id = %q, want reply_1", got)
}
if got := mustStringField(t, data, "reaction_type", "data.reaction_type"); got != "THUMBSUP" {
t.Fatalf("reaction_type = %q, want THUMBSUP", got)
}
if got := mustStringField(t, data, "action", "data.action"); got != "add" {
t.Fatalf("action = %q, want add", got)
}
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
}
func TestDriveReactReplyExecuteViaWikiDelete(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "slidesFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v2/files/slidesFromWiki/comments/reaction",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "slides" {
t.Errorf("file_type = %q, want slides", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--token", "wikiResource",
"--type", "wiki",
"--reply-id", "reply_1",
"--emoji", "ThumbsDown",
"--action", "delete",
"--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("failed to decode captured request body: %v", err)
}
if got := mustStringField(t, body, "action", "request.action"); got != "delete" {
t.Fatalf("request action = %q, want delete", got)
}
if got := mustStringField(t, body, "reaction_type", "request.reaction_type"); got != "ThumbsDown" {
t.Fatalf("request reaction_type = %q, want ThumbsDown (case preserved)", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "slides" {
t.Fatalf("file_type = %q, want slides", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveReactReplyValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "empty reply id",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", " ",
"--emoji", "THUMBSUP",
"--action", "add",
},
wantErr: "--reply-id must not be empty",
wantParam: "--reply-id",
},
{
name: "unknown emoji",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "FOOBAR",
"--action", "add",
},
wantErr: `unknown --emoji "FOOBAR"`,
wantParam: "--emoji",
},
{
name: "emoji is case sensitive",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "thumbsup",
"--action", "add",
},
wantErr: `unknown --emoji "thumbsup"`,
wantParam: "--emoji",
},
{
name: "invalid action",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "toggle",
},
wantErr: `invalid value "toggle" for --action`,
wantParam: "--action",
},
{
name: "unsupported url type",
args: []string{
"+react-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
},
wantErr: "reply reaction supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveReactReply, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestParseDriveReactReplyEmoji(t *testing.T) {
t.Parallel()
valid := []string{"THUMBSUP", "ThumbsDown", "Yes", "2021", " HEART "}
for _, in := range valid {
got, err := parseDriveReactReplyEmoji(in)
if err != nil {
t.Fatalf("parseDriveReactReplyEmoji(%q) unexpected error: %v", in, err)
}
if got != strings.TrimSpace(in) {
t.Fatalf("parseDriveReactReplyEmoji(%q) = %q, want %q", in, got, strings.TrimSpace(in))
}
}
for _, in := range []string{"", " ", "YES", "heart", "THUMBS_UP"} {
if _, err := parseDriveReactReplyEmoji(in); err == nil {
t.Fatalf("parseDriveReactReplyEmoji(%q) expected error, got nil", in)
}
}
}
func TestParseDriveReactReplyAction(t *testing.T) {
t.Parallel()
for in, want := range map[string]string{"add": "add", " DELETE ": "delete", "Add": "add"} {
got, err := parseDriveReactReplyAction(in)
if err != nil {
t.Fatalf("parseDriveReactReplyAction(%q) unexpected error: %v", in, err)
}
if got != want {
t.Fatalf("parseDriveReactReplyAction(%q) = %q, want %q", in, got, want)
}
}
if _, err := parseDriveReactReplyAction("toggle"); err == nil {
t.Fatal("parseDriveReactReplyAction(toggle) expected error, got nil")
}
}
func TestDriveReactReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v2/files/docxResource/comments/reaction",
Body: map[string]interface{}{
"code": 1069301,
"msg": "reply not found",
},
})
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "reply not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
assertDriveCommentAPIError(t, err, 1069301)
}
func TestDriveReactReplyWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx"},
},
},
})
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--reply-id", "reply_1",
"--emoji", "THUMBSUP",
"--action", "add",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveReactReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--reply-id", "reply_1",
"--emoji", "HEART",
"--action", "add",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "POST" {
t.Fatalf("api[0].method = %q, want POST", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/drive/v2/files/docxResource/comments/reaction") {
t.Fatalf("api[0].url = %q, want v2 reaction path", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if got := mustStringField(t, body, "reaction_type", "api[0].body.reaction_type"); got != "HEART" {
t.Fatalf("body.reaction_type = %q, want HEART", got)
}
if got := mustStringField(t, body, "action", "api[0].body.action"); got != "add" {
t.Fatalf("body.action = %q, want add", got)
}
if got := mustStringField(t, body, "reply_id", "api[0].body.reply_id"); got != "reply_1" {
t.Fatalf("body.reply_id = %q, want reply_1", got)
}
}
func TestDriveReactReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveReactReply, []string{
"+react-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--reply-id", "reply_1",
"--emoji", "OK",
"--action", "delete",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "POST" {
t.Fatalf("api[1].method = %q, want POST", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/reaction") {
t.Fatalf("api[1].url = %q, want reaction path", got)
}
body := mustMapValue(t, step2["body"], "api[1].body")
if got := mustStringField(t, body, "action", "api[1].body.action"); got != "delete" {
t.Fatalf("api[1].body.action = %q, want delete", got)
}
}

View File

@@ -0,0 +1,168 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveCommentSolvedSpec struct {
Ref driveCommentRef
CommentID string
Solved bool
}
func (s driveCommentSolvedSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"is_solved": s.Solved,
}
}
// driveCommentSolvedConfig parameterizes the two solved-state shortcuts:
// they share one PATCH endpoint whose body is only {is_solved}, so the
// commands differ solely in direction and wording.
type driveCommentSolvedConfig struct {
Command string
Description string
Label string // driveCommentOp label used in unsupported-type errors
Action string // echoed in output and dry-run descriptions
Verb string // progress-line verb
Solved bool
Tip string // direction-specific tip (counterpart pointer)
}
// DriveResolveComment marks a comment solved through the Drive comment patch
// API, while accepting Wiki URLs/tokens and resolving them to the underlying
// object. Reopening is the separate +restore-comment command.
var DriveResolveComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
Command: "+resolve-comment",
Description: "Resolve (mark solved) a comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Label: "comment resolve",
Action: "resolve",
Verb: "Resolving",
Solved: true,
Tip: "To reopen a solved comment, use `drive +restore-comment`.",
})
// DriveRestoreComment reopens a solved comment through the same Drive comment
// patch API (is_solved=false).
var DriveRestoreComment = newDriveCommentSolvedShortcut(driveCommentSolvedConfig{
Command: "+restore-comment",
Description: "Restore (reopen) a solved comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Label: "comment restore",
Action: "restore",
Verb: "Restoring",
Solved: false,
Tip: "To mark a comment solved, use `drive +resolve-comment`.",
})
func newDriveCommentSolvedShortcut(cfg driveCommentSolvedConfig) common.Shortcut {
op := driveCommentOp{
Label: cfg.Label,
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
readSpec := func(runtime *common.RuntimeContext) (driveCommentSolvedSpec, error) {
ref, err := resolveDriveCommentInput(op, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveCommentSolvedSpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveCommentSolvedSpec{}, err
}
return driveCommentSolvedSpec{Ref: ref, CommentID: commentID, Solved: cfg.Solved}, nil
}
return common.Shortcut{
Service: "drive",
Command: cfg.Command,
Description: cfg.Description,
Risk: "write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(op),
common.Flag{Name: "comment-id", Desc: fmt.Sprintf("comment ID to %s (from drive +list-comments)", cfg.Action), Required: true},
),
Tips: []string{
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
cfg.Tip,
"Back-to-back solved-state flips on the same comment can hit server rate limiting (HTTP 429); space out consecutive calls or retry after a short delay.",
"Wiki URLs/tokens are resolved to the underlying document automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveCommentSolvedDryRun(cfg, spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readSpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, op, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "%s comment %s in %s...\n", cfg.Verb, spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
if _, err := runtime.CallAPITyped(
"PATCH",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"action": cfg.Action,
"is_solved": spec.Solved,
"updated": true,
}), nil)
return nil
},
}
}
func buildDriveCommentSolvedDryRun(cfg driveCommentSolvedConfig, spec driveCommentSolvedSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc(fmt.Sprintf("2-step orchestration: resolve wiki -> %s comment", cfg.Action)).
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
PATCH("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id").
Desc(fmt.Sprintf("[2] %s comment (is_solved=%t) on resolved document", cfg.Verb, cfg.Solved)).
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc(fmt.Sprintf("1-step request: %s comment (is_solved=%t)", cfg.Action, cfg.Solved)).
PATCH("/open-apis/drive/v1/files/:file_token/comments/:comment_id").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,376 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveResolveCommentExecute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--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("failed to decode captured request body: %v", err)
}
if got := body["is_solved"]; got != true {
t.Fatalf("request is_solved = %#v, want true", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "action", "data.action"); got != "resolve" {
t.Fatalf("action = %q, want resolve", got)
}
if got := data["is_solved"]; got != true {
t.Fatalf("is_solved = %#v, want true", got)
}
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
}
func TestDriveRestoreCommentExecuteViaWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_9",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveRestoreComment, []string{
"+restore-comment",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_9",
"--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("failed to decode captured request body: %v", err)
}
if got := body["is_solved"]; got != false {
t.Fatalf("request is_solved = %#v, want false", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "action", "data.action"); got != "restore" {
t.Fatalf("action = %q, want restore", got)
}
if got := data["is_solved"]; got != false {
t.Fatalf("is_solved = %#v, want false", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveResolveCommentExecuteWikiResolvesToBitable(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "baseFromWiki",
},
},
},
})
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/baseFromWiki/comments/comment_3",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "bitable" {
t.Errorf("file_type = %q, want bitable", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_3",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("file_type = %q, want bitable", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
func TestDriveCommentSolvedValidation(t *testing.T) {
tests := []struct {
name string
shortcut string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe comment id",
shortcut: "resolve",
args: []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "empty comment id",
shortcut: "resolve",
args: []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", " ",
},
wantErr: "must not be empty",
wantParam: "--comment-id",
},
{
name: "restore rejects unsupported url type",
shortcut: "restore",
args: []string{
"+restore-comment",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
},
wantErr: "comment restore supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
shortcut := DriveResolveComment
if tt.shortcut == "restore" {
shortcut = DriveRestoreComment
}
err := mountAndRunDrive(t, shortcut, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveResolveCommentInputConflict(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--token", "docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveResolveCommentPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1",
Body: map[string]interface{}{
"code": 1069303,
"msg": "no comment permission",
},
})
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "no comment permission") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveResolveCommentPropagatesWikiResolveError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 230005,
"msg": "wiki node not found",
},
})
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "wiki node not found") {
t.Fatalf("expected wiki resolve error to propagate, got %v", err)
}
}
func TestDriveResolveCommentDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveResolveComment, []string{
"+resolve-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PATCH" {
t.Fatalf("api[1].method = %q, want PATCH", got)
}
body := mustMapValue(t, step2["body"], "api[1].body")
if got := body["is_solved"]; got != true {
t.Fatalf("api[1].body.is_solved = %#v, want true", got)
}
}
func TestDriveRestoreCommentDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveRestoreComment, []string{
"+restore-comment",
"--url", "https://example.larksuite.com/sheets/sheetResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "PATCH" {
t.Fatalf("api[0].method = %q, want PATCH", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/sheetResource/comments/comment_1") {
t.Fatalf("api[0].url = %q, want resolved file and comment tokens", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if got := body["is_solved"]; got != false {
t.Fatalf("api[0].body.is_solved = %#v, want false", got)
}
}
func TestDriveRestoreCommentDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveRestoreComment, []string{
"+restore-comment",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
body := mustMapValue(t, step2["body"], "api[1].body")
if got := body["is_solved"]; got != false {
t.Fatalf("api[1].body.is_solved = %#v, want false", got)
}
}

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -25,7 +26,46 @@ const (
secureLabelOperationUpdate secureLabelOperation = "update"
)
var secureLabelTypes = permApplyTypes
type secureLabelResourceKind struct {
Type string
Path string
}
// secureLabelResourceKinds is intentionally independent from apply-permission:
// the two endpoints accept different resource type contracts.
var secureLabelResourceKinds = []secureLabelResourceKind{
{Type: "doc", Path: "/doc/"},
{Type: "sheet", Path: "/sheets/"},
{Type: "file", Path: "/file/"},
{Type: "wiki", Path: "/wiki/"},
{Type: "bitable", Path: "/base/"},
{Type: "bitable", Path: "/bitable/"},
{Type: "docx", Path: "/docx/"},
{Type: "mindnote", Path: "/mindnote/"},
{Type: "slides", Path: "/slides/"},
}
var secureLabelTypes = func() []string {
types := make([]string, 0, len(secureLabelResourceKinds))
seen := make(map[string]struct{}, len(secureLabelResourceKinds))
for _, resourceKind := range secureLabelResourceKinds {
if _, ok := seen[resourceKind.Type]; ok {
continue
}
seen[resourceKind.Type] = struct{}{}
types = append(types, resourceKind.Type)
}
return types
}()
func secureLabelTypeAllowed(docType string) bool {
for _, allowedType := range secureLabelTypes {
if docType == allowedType {
return true
}
}
return false
}
// DriveSecureLabelList lists secure labels available to the current user.
var DriveSecureLabelList = common.Shortcut{
@@ -81,6 +121,7 @@ var DriveSecureLabelUpdate = common.Shortcut{
AuthTypes: []string{"user"},
Tips: []string{
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
"When updating many files, serialize requests and back off on rate_limit errors.",
},
@@ -146,8 +187,94 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
return params
}
// resolveSecureLabelTarget owns secure-label URL inference and type errors so
// changes to another endpoint cannot widen this command's accepted resources.
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
raw = strings.TrimSpace(raw)
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if explicitType != "" && !secureLabelTypeAllowed(explicitType) {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --type %q: allowed values are %s",
explicitType,
strings.Join(secureLabelTypes, ", "),
).WithParam("--type")
}
if strings.Contains(raw, "://") {
ref, ok := parseSecureLabelResourceURL(raw)
if !ok {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
token, docType = ref.Token, ref.Type
if explicitType != "" && explicitType != docType {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
docType,
).WithParam("--type")
}
} else {
token = raw
docType = explicitType
}
if docType == "" {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(secureLabelTypes, ", "),
).WithParam("--type")
}
if err := validateSecureLabelToken(token); err != nil {
return "", "", err
}
return token, docType, nil
}
func parseSecureLabelResourceURL(rawURL string) (common.ResourceRef, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return common.ResourceRef{}, false
}
escapedPath := parsed.EscapedPath()
for _, resourceKind := range secureLabelResourceKinds {
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
continue
}
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
if escapedToken == "" || strings.Contains(escapedToken, "/") {
return common.ResourceRef{}, false
}
token, err := url.PathUnescape(escapedToken)
if err != nil || token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func validateSecureLabelToken(token string) error {
if err := validate.ResourceName(token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
if token == "." || strings.Contains(token, "/") {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token must be a non-dot single path segment",
).WithParam("--token")
}
return nil
}
// normalizeSecureLabelID trims a label id and rejects display names before the

View File

@@ -159,6 +159,256 @@ func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
}
}
func TestResolveSecureLabelTarget_URLAndBareToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
explicitType string
wantToken string
wantType string
}{
{"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"},
{"docx URL", "https://example.feishu.cn/docx/doxTok", "", "doxTok", "docx"},
{"sheet URL", "https://example.feishu.cn/sheets/shtTok", "", "shtTok", "sheet"},
{"base URL", "https://example.feishu.cn/base/basTok", "", "basTok", "bitable"},
{"bitable URL", "https://example.feishu.cn/bitable/bitTok", "", "bitTok", "bitable"},
{"file URL", "https://example.feishu.cn/file/boxTok", "", "boxTok", "file"},
{"mindnote URL", "https://example.feishu.cn/mindnote/mndTok", "", "mndTok", "mindnote"},
{"slides URL", "https://example.feishu.cn/slides/sldTok", "", "sldTok", "slides"},
{"legacy doc URL", "https://example.feishu.cn/doc/docTok", "", "docTok", "doc"},
{"bare token with explicit type", "doxBareTok", "docx", "doxBareTok", "docx"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
token, docType, err := resolveSecureLabelTarget(tt.raw, tt.explicitType)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
if token != tt.wantToken || docType != tt.wantType {
t.Fatalf("token/type = %q/%q, want %q/%q", token, docType, tt.wantToken, tt.wantType)
}
})
}
}
func TestResolveSecureLabelTarget_RejectsUnsafeOrAmbiguousTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
explicitType string
wantParam string
}{
{"bare traversal token", "..", "docx", "--token"},
{"bare dot token", ".", "docx", "--token"},
{"URL traversal token", "https://example.feishu.cn/docx/../victim", "", "--token"},
{"marker outside resource root", "https://example.feishu.cn/share/docx/doxUnexpected", "", "--token"},
{"encoded path separator", "https://example.feishu.cn/docx/doxTarget%2Fother", "", "--token"},
{"encoded fragment separator", "https://example.feishu.cn/docx/doxTarget%23other", "", "--token"},
{"conflicting URL type", "https://example.feishu.cn/docx/doxTok", "wiki", "--type"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := resolveSecureLabelTarget(tt.raw, tt.explicitType)
if err == nil {
t.Fatalf("resolveSecureLabelTarget(%q, %q) unexpectedly succeeded", tt.raw, tt.explicitType)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != tt.wantParam {
t.Fatalf("error param = %q, want %q", validationErr.Param, tt.wantParam)
}
})
}
}
func TestResolveSecureLabelTarget_RejectsInvalidInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
wantParam string
wantMessage string
}{
{
name: "empty token",
raw: " \t ",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "apps page URL is unsupported",
raw: "https://example.feishu.cn/page/appMetaTok",
wantParam: "--token",
wantMessage: "could not infer token from URL",
},
{
name: "bare token requires type",
raw: "doxBareTok",
wantParam: "--type",
wantMessage: "--type is required when --token is a bare token",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
token, docType, err := resolveSecureLabelTarget(tt.raw, "")
if err == nil {
t.Fatal("resolve target error = nil, want validation error")
}
if token != "" || docType != "" {
t.Fatalf("token/type = %q/%q, want empty values", token, docType)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf(
"error category/subtype = %q/%q, want %q/%q",
problem.Category,
problem.Subtype,
errs.CategoryValidation,
errs.SubtypeInvalidArgument,
)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != tt.wantParam {
t.Fatalf("error param = %q, want %q", validationErr.Param, tt.wantParam)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want message containing %q", err, tt.wantMessage)
}
})
}
}
func TestDriveSecureLabelUpdate_RejectsAppsTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantMessage string
}{
{
name: "apps page URL",
args: []string{
"--token", "https://example.feishu.cn/page/appMetaTok",
},
wantMessage: "could not infer token from URL",
},
{
name: "explicit apps type",
args: []string{
"--token", "appBareTok",
"--type", "apps",
},
wantMessage: `invalid value "apps" for --type`,
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
args := append([]string{
"+secure-label-update",
}, tt.args...)
args = append(args,
"--label-id", "7217780879644737539",
"--dry-run", "--as", "user",
)
err := mountAndRunDrive(t, DriveSecureLabelUpdate, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want message containing %q", err, tt.wantMessage)
}
})
}
}
func TestDriveSecureLabelUpdate_RejectsURLMarkersOutsidePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
}{
{
name: "query",
url: "https://example.feishu.cn/share?redirect=/docx/doxQueryTok",
},
{
name: "fragment",
url: "https://example.feishu.cn/share#/docx/doxFragmentTok",
},
{
name: "empty host",
url: "https:///docx/doxNoHostTok",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", tt.url,
"--label-id", "7217780879644737539",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected URL validation error for %q", tt.url)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(error) ok = false, error = %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf(
"error category/subtype = %q/%q, want %q/%q",
problem.Category,
problem.Subtype,
errs.CategoryValidation,
errs.SubtypeInvalidArgument,
)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--token" {
t.Fatalf("error param = %q, want %q", validationErr.Param, "--token")
}
})
}
}
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveUpdateReplyOp = driveCommentOp{
Label: "reply update",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveUpdateReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyID string
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
}
func (s driveUpdateReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"content": map[string]interface{}{
"elements": driveReplyV1Elements(s.ReplyElements),
},
}
}
// DriveUpdateReply replaces the content of an existing comment reply through
// the Drive comment reply update API (PUT .../comments/:comment_id/replies/:reply_id),
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
var DriveUpdateReply = common.Shortcut{
Service: "drive",
Command: "+update-reply",
Description: "Update the content of a comment reply on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:write_only"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveUpdateReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID that owns the reply (from drive +list-comments)", Required: true},
common.Flag{Name: "reply-id", Desc: "reply ID to update (from drive +list-replies)", Required: true},
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
),
Tips: []string{
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
"The update replaces the whole reply content; there is no partial edit.",
"Reply IDs come from `drive +list-replies` (items[].reply_id); updating a comment's root reply rewrites the comment body itself.",
"Only the identity that created a reply can update it; other identities get API error 1069303 (forbidden). Use the same --as identity that created the reply.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveUpdateReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveUpdateReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveUpdateReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveUpdateReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveUpdateReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Updating reply %s of comment %s in %s...\n", spec.ReplyID, spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies/%s",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
validate.EncodePathSegment(spec.ReplyID),
)
if _, err := runtime.CallAPITyped(
"PUT",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
); err != nil {
return err
}
runtime.Out(driveCommentTargetOutput(target, map[string]interface{}{
"comment_id": spec.CommentID,
"reply_id": spec.ReplyID,
"updated": true,
}), nil)
return nil
},
}
func readDriveUpdateReplySpec(runtime *common.RuntimeContext) (driveUpdateReplySpec, error) {
ref, err := resolveDriveCommentInput(driveUpdateReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveUpdateReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveUpdateReplySpec{}, err
}
replyID := strings.TrimSpace(runtime.Str("reply-id"))
if err := validateDriveCommentPathID(replyID, "--reply-id"); err != nil {
return driveUpdateReplySpec{}, err
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return driveUpdateReplySpec{}, err
}
return driveUpdateReplySpec{
Ref: ref,
CommentID: commentID,
ReplyID: replyID,
ReplyElements: replyElements,
}, nil
}
func buildDriveUpdateReplyDryRun(spec driveUpdateReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> update comment reply").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
PUT("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies/:reply_id").
Desc("[2] Update reply content on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}
return common.NewDryRunAPI().
Desc("1-step request: update comment reply").
PUT("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies/:reply_id").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID).
Set("reply_id", spec.ReplyID)
}

View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveUpdateReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"更新后的回复"},{"type":"mention_user","mention_user":"ou_123"}]`,
"--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("failed to decode captured request body: %v", err)
}
content := mustMapValue(t, body["content"], "request.content")
elements := mustSliceValue(t, content["elements"], "request.content.elements")
if len(elements) != 2 {
t.Fatalf("len(request.content.elements) = %d, want 2", len(elements))
}
first := mustMapValue(t, elements[0], "request.content.elements[0]")
if got := mustStringField(t, first, "type", "request.content.elements[0].type"); got != "text_run" {
t.Fatalf("request element type = %q, want text_run", got)
}
firstText := mustMapValue(t, first["text_run"], "request.content.elements[0].text_run")
if got := mustStringField(t, firstText, "text", "request.content.elements[0].text_run.text"); got != "更新后的回复" {
t.Fatalf("text_run.text = %q, want 更新后的回复", got)
}
second := mustMapValue(t, elements[1], "request.content.elements[1]")
if got := mustStringField(t, second, "type", "request.content.elements[1].type"); got != "person" {
t.Fatalf("request element type = %q, want person", got)
}
secondPerson := mustMapValue(t, second["person"], "request.content.elements[1].person")
if got := mustStringField(t, secondPerson, "user_id", "request.content.elements[1].person.user_id"); got != "ou_123" {
t.Fatalf("person.user_id = %q, want ou_123", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_2" {
t.Fatalf("reply_id = %q, want reply_2", got)
}
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
}
func TestDriveUpdateReplyExecuteViaWiki(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": "sheetFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/drive/v1/files/sheetFromWiki/comments/comment_1/replies/reply_2",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "sheet" {
t.Errorf("file_type = %q, want sheet", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--token", "wikiResource",
"--type", "wiki",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"updated from wiki"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "sheet" {
t.Fatalf("file_type = %q, want sheet", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
}
// The reply-update endpoint does not declare the 100-element cap (only
// reply-create does), so +update-reply must NOT reject >100 elements locally.
func TestDriveUpdateReplyDoesNotCapElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", "[" + strings.Join(elems, ",") + "]",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("+update-reply must not cap element count locally, got %v", err)
}
}
func TestDriveUpdateReplyValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
wantParam string
}{
{
name: "unsafe comment id",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "path traversal",
wantParam: "--comment-id",
},
{
name: "unsafe reply id",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "../reply",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "path traversal",
wantParam: "--reply-id",
},
{
name: "empty reply id",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", " ",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "--reply-id must not be empty",
wantParam: "--reply-id",
},
{
name: "invalid content json",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `not-json`,
},
wantErr: "--content is not valid JSON",
wantParam: "--content",
},
{
name: "unsupported url type",
args: []string{
"+update-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
},
wantErr: "reply update supports doc, docx, sheet, file, slides, bitable, base, apps, wiki",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateReply, append(tt.args, "--as", "user"), f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveCommentValidationError(t, err, tt.wantParam)
})
}
}
func TestDriveUpdateReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies/reply_2",
Body: map[string]interface{}{
"code": 1069307,
"msg": "no permission to edit reply",
},
})
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "no permission to edit reply") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveUpdateReplyWikiNodeIncompleteResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_token": "tokenOnly"},
},
},
})
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "incomplete node data") {
t.Fatalf("expected incomplete-node error, got %v", err)
}
}
func TestDriveUpdateReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"更新后的回复"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "method", "api[0].method"); got != "PUT" {
t.Fatalf("api[0].method = %q, want PUT", got)
}
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies/reply_2") {
t.Fatalf("api[0].url = %q, want resolved path segments", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
content := mustMapValue(t, body["content"], "api[0].body.content")
elements := mustSliceValue(t, content["elements"], "api[0].body.content.elements")
if len(elements) != 1 {
t.Fatalf("api[0].body.content.elements length = %d, want 1", len(elements))
}
}
func TestDriveUpdateReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateReply, []string{
"+update-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--reply-id", "reply_2",
"--content", `[{"type":"text","text":"x"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step1 := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, step1, "url", "api[0].url"); !strings.Contains(got, "/wiki/v2/spaces/get_node") {
t.Fatalf("api[0].url = %q, want wiki get_node", got)
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "method", "api[1].method"); got != "PUT" {
t.Fatalf("api[1].method = %q, want PUT", got)
}
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies/reply_2") {
t.Fatalf("api[1].url = %q, want resolved comment and reply IDs", got)
}
}

View File

@@ -16,6 +16,14 @@ func Shortcuts() []common.Shortcut {
DriveCover,
DriveAddComment,
DriveListComments,
DriveBatchQueryComments,
DriveResolveComment,
DriveRestoreComment,
DriveAddReply,
DriveListReplies,
DriveUpdateReply,
DriveDeleteReply,
DriveReactReply,
DriveExport,
DriveExportDownload,
DriveImport,
@@ -32,6 +40,8 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveMemberList,
DrivePermissionGetSetting,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -23,6 +23,14 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+cover",
"+add-comment",
"+list-comments",
"+batch-query-comments",
"+resolve-comment",
"+restore-comment",
"+add-reply",
"+list-replies",
"+update-reply",
"+delete-reply",
"+react-reply",
"+export",
"+export-download",
"+import",
@@ -39,6 +47,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+member-list",
"+permission-get-setting",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -14,6 +14,9 @@ func Shortcuts() []common.Shortcut {
WhiteboardUpdateOld,
WhiteboardExport,
WhiteboardQuery,
WhiteboardNodeCreate,
WhiteboardNodeUpdate,
WhiteboardNodeDelete,
}
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type whiteboardNodeBatchPayload struct {
Nodes []map[string]interface{} `json:"nodes"`
}
func parseWhiteboardNodeBatchPayload(raw []byte, requireID bool) (whiteboardNodeBatchPayload, error) {
var payload whiteboardNodeBatchPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).
WithParam("--source").
WithCause(err)
}
if len(payload.Nodes) == 0 {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, `--source must include non-empty "nodes"`).
WithParam("--source")
}
if requireID {
for i, node := range payload.Nodes {
id, ok := node["id"].(string)
if !ok || strings.TrimSpace(id) == "" {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "nodes[%d].id must be a non-empty string", i).
WithParam("--source")
}
}
}
return payload, nil
}
func parseWhiteboardNodeIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids is required").
WithParam("--node-ids")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids item %d must not be empty", i+1).
WithParam("--node-ids")
}
if _, ok := seen[id]; ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate node id %q", id).
WithParam("--node-ids")
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateOptionalWhiteboardNodeIdempotentToken(raw string) error {
if err := common.RejectDangerousCharsTyped("--idempotent-token", raw); err != nil {
return err
}
if raw != "" && len(raw) < 10 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").
WithParam("--idempotent-token")
}
return nil
}
func callWhiteboardNodeWrite(ctx context.Context, runtime *common.RuntimeContext, method, apiPath string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
Body: body,
QueryParams: whiteboardNodeQueryParams(params),
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, err
}
data, classifyErr := runtime.ClassifyAPIResponse(resp)
if classifyErr == nil {
return data, nil
}
if resp.StatusCode >= http.StatusBadRequest {
return data, classifyErr
}
if isWhiteboardNodeNonObjectSuccess(classifyErr, resp) {
return nil, nil
}
return data, classifyErr
}
func whiteboardNodeQueryParams(params map[string]interface{}) larkcore.QueryParams {
query := make(larkcore.QueryParams)
for key, value := range params {
switch typed := value.(type) {
case []string:
for _, item := range typed {
query.Add(key, item)
}
case []interface{}:
for _, item := range typed {
query.Add(key, whiteboardNodeQueryValue(item))
}
default:
query.Set(key, whiteboardNodeQueryValue(value))
}
}
return query
}
func whiteboardNodeQueryValue(value interface{}) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func isWhiteboardNodeNonObjectSuccess(err error, resp *larkcore.ApiResp) bool {
if resp == nil {
return false
}
if _, ok := errs.ProblemOf(err); !ok {
return false
}
result, parseErr := client.ParseJSONResponse(resp)
if parseErr != nil {
return false
}
_, isObject := result.(map[string]interface{})
return !isObject
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"testing"
)
func TestShortcutsIncludesWhiteboardNodeCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+update",
"+export",
"+query",
"+node-create",
"+node-update",
"+node-delete",
}
seen := make(map[string]bool, len(got))
for _, shortcut := range got {
if seen[shortcut.Command] {
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
}
seen[shortcut.Command] = true
}
for _, command := range want {
if !seen[command] {
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
}
}
}
func TestParseWhiteboardNodeBatchPayload_MissingNodes(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{}`), false)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_EmptyNodes(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[]}`), false)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_InvalidJSONPreservesCause(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`not-json`), false)
assertValidationParam(t, err, "--source", true)
}
func TestParseWhiteboardNodeBatchPayload_RequireIDMissingID(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"text":{"text":"x"}}]}`), true)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_RequireIDBlankID(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":" ","text":{"text":"x"}}]}`), true)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_PreservesArbitraryFields(t *testing.T) {
t.Parallel()
payload, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":"node-1","type":"shape","custom":{"x":1},"points":[1,2]}]}`), true)
if err != nil {
t.Fatalf("parseWhiteboardNodeBatchPayload() error = %v", err)
}
if len(payload.Nodes) != 1 {
t.Fatalf("len(payload.Nodes) = %d, want 1", len(payload.Nodes))
}
node := payload.Nodes[0]
if got := node["id"]; got != "node-1" {
t.Errorf("node[id] = %v, want node-1", got)
}
if got := node["type"]; got != "shape" {
t.Errorf("node[type] = %v, want shape", got)
}
custom, ok := node["custom"].(map[string]interface{})
if !ok {
t.Fatalf("node[custom] = %T, want map[string]interface{}", node["custom"])
}
if got := custom["x"]; got != float64(1) {
t.Errorf("node[custom][x] = %v, want 1", got)
}
points, ok := node["points"].([]interface{})
if !ok {
t.Fatalf("node[points] = %T, want []interface{}", node["points"])
}
if len(points) != 2 || points[0] != float64(1) || points[1] != float64(2) {
t.Errorf("node[points] = %#v, want [1 2]", points)
}
}
func TestParseWhiteboardNodeIDs_TrimsItems(t *testing.T) {
t.Parallel()
ids, err := parseWhiteboardNodeIDs(" nodeA, nodeB ,nodeC ")
if err != nil {
t.Fatalf("parseWhiteboardNodeIDs() error = %v", err)
}
want := []string{"nodeA", "nodeB", "nodeC"}
if len(ids) != len(want) {
t.Fatalf("len(ids) = %d, want %d", len(ids), len(want))
}
for i := range want {
if ids[i] != want[i] {
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i])
}
}
}
func TestParseWhiteboardNodeIDs_RejectsEmptyInput(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs(" ")
assertValidationParam(t, err, "--node-ids", false)
}
func TestParseWhiteboardNodeIDs_RejectsEmptyItems(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs("nodeA, ,nodeB")
assertValidationParam(t, err, "--node-ids", false)
}
func TestParseWhiteboardNodeIDs_RejectsDuplicateIDs(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs("nodeA,nodeB,nodeA")
assertValidationParam(t, err, "--node-ids", false)
}
func TestValidateOptionalWhiteboardNodeIdempotentToken_TooShort(t *testing.T) {
t.Parallel()
err := validateOptionalWhiteboardNodeIdempotentToken("short")
assertValidationParam(t, err, "--idempotent-token", false)
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeCreateScopes = []string{"board:whiteboard:node:create"}
var wbNodeCreateAuthTypes = []string{"user", "bot"}
var wbNodeCreateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to create nodes in. You need edit permission on the whiteboard.", Required: true},
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array.`, Required: true, Input: []string{common.Stdin, common.File}},
{Name: "idempotent-token", Desc: "idempotent token to make create requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
type whiteboardNodeCreateReq struct {
Nodes []map[string]interface{} `json:"nodes"`
}
func wbNodeCreateValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
return err
}
func wbNodeCreateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
dry := common.NewDryRunAPI().
POST(wbNodeCreateDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeCreateReq{Nodes: payload.Nodes}).
Desc("create nodes in the whiteboard.")
if params := wbNodeCreateParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeCreateExecute(_ context.Context, runtime *common.RuntimeContext) error {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
http.MethodPost,
wbNodeCreateURL(runtime.Str("whiteboard-token")),
wbNodeCreateParams(runtime),
whiteboardNodeCreateReq{Nodes: payload.Nodes},
)
if err != nil {
return err
}
nodeIDs, err := whiteboardNodeCreateIDs(data)
if err != nil {
return err
}
outData := map[string]string{}
if nodeIDs != nil {
outData["ids"] = strings.Join(nodeIDs, ",")
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
}
fmt.Fprintf(w, "Create whiteboard nodes success")
})
return nil
}
func wbNodeCreateURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token))
}
func wbNodeCreateDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))
}
func wbNodeCreateParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
func whiteboardNodeCreateIDs(data map[string]interface{}) ([]string, error) {
switch raw := data["ids"].(type) {
case nil:
return nil, nil
case []interface{}:
out := make([]string, 0, len(raw))
for i, value := range raw {
id, ok := value.(string)
if !ok {
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids[%d] must be a string", i)
}
out = append(out, id)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids must be an array of strings")
}
}
// WhiteboardNodeCreate registers the `whiteboard +node-create` shortcut.
var WhiteboardNodeCreate = common.Shortcut{
Service: "whiteboard",
Command: "+node-create",
Description: "Create nodes in an existing whiteboard.",
Risk: "write",
Scopes: wbNodeCreateScopes,
AuthTypes: wbNodeCreateAuthTypes,
Flags: wbNodeCreateFlags,
Validate: wbNodeCreateValidate,
DryRun: wbNodeCreateDryRun,
Execute: wbNodeCreateExecute,
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeCreateValidate_InvalidSourceTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"source": "not-json",
}, nil)
err := wbNodeCreateValidate(context.Background(), rt)
assertValidationParam(t, err, "--source", true)
}
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"idempotent-token": "create-token-12345",
"source": `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
}, nil)
dryRun := wbNodeCreateDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeCreateDryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "POST" {
t.Fatalf("method = %q, want POST", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes" {
t.Fatalf("url = %q, want node-create URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "create-token-12345" {
t.Fatalf("params.client_token = %#v, want create-token-12345", got.API[0].Params["client_token"])
}
nodes, ok := got.API[0].Body["nodes"].([]interface{})
if !ok || len(nodes) != 1 {
t.Fatalf("body.nodes = %#v, want one node", got.API[0].Body["nodes"])
}
node, ok := nodes[0].(map[string]interface{})
if !ok || node["type"] != "composite_shape" {
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
}
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
}
}
func TestWhiteboardNodeCreateExecute_PostsNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"node-1"},
},
},
}
reg.Register(stub)
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
nodes, ok := body["nodes"].([]interface{})
if !ok || len(nodes) != 1 {
t.Fatalf("body.nodes = %#v, want one node; body=%s", body["nodes"], string(stub.CapturedBody))
}
node, ok := nodes[0].(map[string]interface{})
if !ok || node["type"] != "composite_shape" {
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
}
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
}
if !strings.Contains(stdout.String(), `"ids": "node-1"`) {
t.Fatalf("stdout=%s, want ids node-1", stdout.String())
}
}
func TestWhiteboardNodeCreateExecute_AllowsMissingIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
}
func TestWhiteboardNodeCreateExecute_RejectsMalformedIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []interface{}{"node-1", 2},
},
},
})
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout)
if err == nil {
t.Fatal("expected malformed ids error, got nil")
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError", err)
}
if internalErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeDeleteScopes = []string{"board:whiteboard:node:delete"}
var wbNodeDeleteAuthTypes = []string{"user", "bot"}
var wbNodeDeleteFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to delete nodes from. You need edit permission on the whiteboard.", Required: true},
{Name: "node-ids", Desc: "comma-separated whiteboard node IDs to delete.", Required: true},
{Name: "idempotent-token", Desc: "idempotent token to make delete requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
type whiteboardNodeDeleteReq struct {
IDs []string `json:"ids"`
}
func wbNodeDeleteValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
return err
}
func wbNodeDeleteDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
if err != nil {
return common.NewDryRunAPI().Desc("parse node ids failed: " + err.Error())
}
dry := common.NewDryRunAPI().
DELETE(wbNodeDeleteDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeDeleteReq{IDs: ids}).
Desc("delete nodes from the whiteboard.")
if params := wbNodeDeleteParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeDeleteExecute(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
if err != nil {
return err
}
if _, err := callWhiteboardNodeWrite(
ctx,
runtime,
http.MethodDelete,
wbNodeDeleteURL(runtime.Str("whiteboard-token")),
wbNodeDeleteParams(runtime),
whiteboardNodeDeleteReq{IDs: ids},
); err != nil {
return err
}
outData := map[string]interface{}{
"ids": strings.Join(ids, ","),
"count": len(ids),
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d nodes deleted.\n", len(ids))
fmt.Fprintf(w, "Delete whiteboard nodes success")
})
return nil
}
func wbNodeDeleteURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(token))
}
func wbNodeDeleteDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))
}
func wbNodeDeleteParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
// WhiteboardNodeDelete registers the `whiteboard +node-delete` shortcut.
var WhiteboardNodeDelete = common.Shortcut{
Service: "whiteboard",
Command: "+node-delete",
Description: "Delete nodes from an existing whiteboard.",
Risk: "high-risk-write",
Scopes: wbNodeDeleteScopes,
AuthTypes: wbNodeDeleteAuthTypes,
Flags: wbNodeDeleteFlags,
Validate: wbNodeDeleteValidate,
DryRun: wbNodeDeleteDryRun,
Execute: wbNodeDeleteExecute,
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeDeleteValidate_InvalidNodeIDsTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"node-ids": "nodeA,,nodeB",
}, nil)
err := wbNodeDeleteValidate(context.Background(), rt)
assertValidationParam(t, err, "--node-ids", false)
}
func TestWhiteboardNodeDeleteMetadata_RiskHighRiskWrite(t *testing.T) {
t.Parallel()
if WhiteboardNodeDelete.Risk != "high-risk-write" {
t.Fatalf("Risk = %q, want high-risk-write", WhiteboardNodeDelete.Risk)
}
}
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"node-ids": "nodeA,nodeB",
"idempotent-token": "delete-token-12345",
}, nil)
dryRun := wbNodeDeleteDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeDeleteDryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "DELETE" {
t.Fatalf("method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_delete" {
t.Fatalf("url = %q, want masked node-delete URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "delete-token-12345" {
t.Fatalf("params.client_token = %#v, want delete-token-12345", got.API[0].Params["client_token"])
}
ids, ok := got.API[0].Body["ids"].([]interface{})
if !ok || len(ids) != 2 {
t.Fatalf("body.ids = %#v, want two ids", got.API[0].Body["ids"])
}
if ids[0] != "nodeA" || ids[1] != "nodeB" {
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
}
}
func TestWhiteboardNodeDeleteExecute_PostsIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_delete",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
args := []string{"+node-delete", "--whiteboard-token", "test-board", "--node-ids", "nodeA,nodeB"}
if err := runUpdateShortcut(t, WhiteboardNodeDelete, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
ids, ok := body["ids"].([]interface{})
if !ok || len(ids) != 2 {
t.Fatalf("body.ids = %#v, want two ids; body=%s", body["ids"], string(stub.CapturedBody))
}
if ids[0] != "nodeA" || ids[1] != "nodeB" {
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
}
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeUpdateScopes = []string{"board:whiteboard:node:update"}
var wbNodeUpdateAuthTypes = []string{"user", "bot"}
var wbNodeUpdateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update nodes in. You need edit permission on the whiteboard.", Required: true},
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array. Each node must include "id"; the batch_update body sends the full nodes array.`, Required: true, Input: []string{common.Stdin, common.File}},
{Name: "idempotent-token", Desc: "idempotent token to make batch update requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
func wbNodeUpdateValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
return err
}
func wbNodeUpdateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
dry := common.NewDryRunAPI().
PUT(wbNodeBatchUpdateDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeBatchUpdateBody(payload)).
Desc("batch update nodes in the whiteboard.")
if params := wbNodeUpdateParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
http.MethodPut,
wbNodeBatchUpdateURL(runtime.Str("whiteboard-token")),
wbNodeUpdateParams(runtime),
whiteboardNodeBatchUpdateBody(payload),
)
if err != nil {
return err
}
updatedNodeIDs, err := whiteboardNodeUpdateIDs(data)
if err != nil {
return err
}
outData := map[string]interface{}{
"ids": strings.Join(updatedNodeIDs, ","),
"count": len(updatedNodeIDs),
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d nodes updated.\n", len(updatedNodeIDs))
fmt.Fprintf(w, "Update whiteboard nodes success")
})
return nil
}
func wbNodeBatchUpdateURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", url.PathEscape(token))
}
func wbNodeBatchUpdateDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", common.MaskToken(url.PathEscape(token)))
}
func wbNodeUpdateParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
func whiteboardNodeBatchUpdateBody(payload whiteboardNodeBatchPayload) map[string]interface{} {
return map[string]interface{}{"nodes": payload.Nodes}
}
func whiteboardNodeUpdateIDs(data map[string]interface{}) ([]string, error) {
switch raw := data["ids"].(type) {
case nil:
return nil, nil
case []interface{}:
out := make([]string, 0, len(raw))
for i, value := range raw {
id, ok := value.(string)
if !ok {
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids[%d] must be a string", i)
}
out = append(out, id)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids must be an array of strings")
}
}
// WhiteboardNodeUpdate registers the `whiteboard +node-update` shortcut.
var WhiteboardNodeUpdate = common.Shortcut{
Service: "whiteboard",
Command: "+node-update",
Description: "Update nodes in an existing whiteboard.",
Risk: "write",
Scopes: wbNodeUpdateScopes,
AuthTypes: wbNodeUpdateAuthTypes,
Flags: wbNodeUpdateFlags,
Tips: []string{
`Pass --source as JSON with a non-empty "nodes" array; each node must include "id".`,
`Execution sends one whiteboard.node batch_update request and preserves node ids in the request body.`,
`Use --idempotent-token for retry-safe batch_update requests; the token is sent as client_token only when provided.`,
},
Validate: wbNodeUpdateValidate,
DryRun: wbNodeUpdateDryRun,
Execute: wbNodeUpdateExecute,
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeUpdateValidate_SourceMissingIDTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"source": `{"nodes":[{"type":"text","text":{"content":"hello"}}]}`,
}, nil)
err := wbNodeUpdateValidate(context.Background(), rt)
assertValidationParam(t, err, "--source", false)
}
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"idempotent-token": "update-token-12345",
"source": `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`,
}, nil)
dryRun := wbNodeUpdateDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeUpdateDryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "PUT" {
t.Fatalf("method = %q, want PUT", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_update" {
t.Fatalf("url = %q, want masked batch_update URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "update-token-12345" {
t.Fatalf("params.client_token = %#v, want update-token-12345", got.API[0].Params["client_token"])
}
nodes, ok := got.API[0].Body["nodes"].([]interface{})
if !ok || len(nodes) != 2 {
t.Fatalf("body.nodes = %#v, want two nodes", got.API[0].Body["nodes"])
}
wantText := []string{"hello A", "hello B"}
for i := range nodes {
node, ok := nodes[i].(map[string]interface{})
if !ok {
t.Fatalf("body.nodes[%d] = %T, want map; nodes=%#v", i, nodes[i], nodes)
}
if node["id"] != []string{"nodeA", "nodeB"}[i] {
t.Fatalf("body.nodes[%d].id = %#v", i, node["id"])
}
text, ok := node["text"].(map[string]interface{})
if !ok || text["content"] != wantText[i] {
t.Fatalf("body.nodes[%d].text = %#v, want content %q", i, node["text"], wantText[i])
}
}
}
func TestWhiteboardNodeUpdateExecute_BatchUpdatesNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
var capturedQuery string
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"nodeA", "nodeB"},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
}
reg.Register(stub)
source := `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source, "--idempotent-token", "update-token-12345"}
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
assertNodeBatchUpdateCapturedBody(t, stub.CapturedBody, []string{"hello A", "hello B"})
if !strings.Contains(capturedQuery, "client_token=update-token-12345") {
t.Fatalf("query = %q, want client_token", capturedQuery)
}
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
}
if !strings.Contains(stdout.String(), `"count": 2`) {
t.Fatalf("stdout=%s, want count 2", stdout.String())
}
}
func TestWhiteboardNodeUpdateExecute_WithoutIdempotentTokenOmitsClientToken(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
var capturedQuery string
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"nodeA"},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
}
reg.Register(stub)
source := `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}}]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if capturedQuery != "" {
t.Fatalf("query = %q, want empty when --idempotent-token is absent", capturedQuery)
}
}
func TestWhiteboardNodeUpdateExecute_BatchFailureReturnsAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 1254001,
"msg": "node not found",
"data": map[string]interface{}{},
},
})
source := `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout)
if err == nil {
t.Fatal("expected batch update failure error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false for %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryAPI)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *errs.APIError reachable via errors.As", err)
}
}
func TestWhiteboardNodeUpdateTips_MentionTemporaryNonAtomicBehavior(t *testing.T) {
t.Parallel()
tips := strings.Join(WhiteboardNodeUpdate.Tips, "\n")
for _, want := range []string{"batch_update", "client_token", "one whiteboard.node batch_update request"} {
if !strings.Contains(tips, want) {
t.Fatalf("tips = %q, want substring %q", tips, want)
}
}
for _, banned := range []string{"fans out", "non-atomic", "Temporary behavior"} {
if strings.Contains(tips, banned) {
t.Fatalf("tips = %q, should not contain old fan-out wording %q", tips, banned)
}
}
}
func assertNodeBatchUpdateCapturedBody(t *testing.T, raw []byte, wantContent []string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(raw))
}
nodes, ok := body["nodes"].([]interface{})
if !ok || len(nodes) != len(wantContent) {
t.Fatalf("body.nodes = %#v, want %d nodes; body=%s", body["nodes"], len(wantContent), string(raw))
}
for i, rawNode := range nodes {
node, ok := rawNode.(map[string]interface{})
if !ok {
t.Fatalf("body.nodes[%d] = %T, want map; body=%s", i, rawNode, string(raw))
}
if _, exists := node["id"]; !exists {
t.Fatalf("body.nodes[%d].id absent; body=%s", i, string(raw))
}
text, ok := node["text"].(map[string]interface{})
if !ok || text["content"] != wantContent[i] {
t.Fatalf("body.nodes[%d].text = %#v, want content %q; body=%s", i, node["text"], wantContent[i], string(raw))
}
}
}

View File

@@ -41,6 +41,7 @@ lark-cli auth login --domain apps
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list``+db-table-get``+db-env-create``+db-data-export`/`+db-data-import``+db-changelog-list``+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list``+db-env-diff`/`+db-env-migrate``+db-recovery-diff`/`+db-recovery-apply``+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
| 逐条执行 SQLSELECT / DML / DDL建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
| 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) |
| **部署/上线应用**"部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
| 创意模式html应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |

View File

@@ -0,0 +1,61 @@
# apps cache 域命令(应用运行时缓存调试)
调试妙搭应用的运行时缓存:查看某个缓存 key 的内容、删除单个 key、清空某个环境的全部缓存。缓存是应用为了加速而临时存放的数据删除或清空后应用下次用到时会自动重新取最新数据。命令事实以 `lark-cli apps +<cmd> --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。
## 何时用
用户要排查「某个缓存 key 里存的是什么 / 有没有命中」、想删掉某个 key 让应用下次拿到最新数据、或想清空某个环境的缓存做快速恢复时。
## 命令一览
| 命令 | 做什么 | 关键参数 |
|---|---|---|
| `+cache-get` | 查一个缓存 key 的内容与信息 | `--key``--environment``--format` |
| `+cache-delete` | 删一个缓存 key重复删不会报错不需 `--yes` | `--key``--environment` |
| `+cache-clear` | 清空指定环境下的全部缓存(**高危** | `--environment``--yes` |
> 所有命令都需 `--app-id`。
## 约定(先读)
- **环境 `--environment dev|online`(可省略)**:缓存按运行环境隔离。不指定时按应用当前的环境配置自动选择——有多环境的应用默认落到开发环境 `dev`,没有多环境的就是线上 `online`;返回结果里的 `environment` 会告诉你这次实际操作的是哪个环境。想固定就显式传。
- **缓存 key 用 `--key` 传**:传业务里使用的那个 key是否合法非空、长度等由服务端校验不合法会返回错误。
- **风险分级**`+cache-clear` 会清掉整个环境的缓存,是高危操作,不带 `--yes` 会被确认关卡拦下;`+cache-delete` 只删单个 key、影响小不需 `--yes`
- **`+cache-get` 的内容有两种展示**`--format json`(默认)原样返回缓存内容,适合精确比对;`--format pretty` 会把内容格式化展开,更便于阅读。
## 各命令
### +cache-get
`--key` 查单个缓存。命中时返回是否存在、剩余有效期TTL、内容及其大小未命中或已过期时只返回 `exists=false`、不带内容。
> 每次查询都会连内容一起返回(没有「只看信息、不取内容」的模式),内容可能较大——只是想确认「在不在 / 还有多久过期」时,留意别占用太多上下文。
```bash
lark-cli apps +cache-get --app-id app_xxx --key spotbonus:2026:winners:list:v1
lark-cli apps +cache-get --app-id app_xxx --environment online --key <key> --format pretty
```
### +cache-delete
删一个缓存 key。**重复删、或删一个本就不存在的 key都算成功**(返回删除数量 0、不会报错删中则返回删除数量 1。删掉后应用下次会自动重新取最新数据影响小故不需 `--yes`
```bash
lark-cli apps +cache-delete --app-id app_xxx --environment dev --key <key>
```
### +cache-clear高危
清空当前应用在**指定环境**下的全部缓存,用于定位不到具体 key 时的快速恢复。影响面是整个环境,必须带 `--yes`;返回本次清除的 key 数量。动手前可先 `--dry-run` 预览将要执行的操作。
```bash
lark-cli apps +cache-clear --app-id app_xxx --environment dev --yes
```
## 错误与边界
- **key 不合法 / 缓存服务暂时不可用**:命令会返回带说明的错误,按 `error.hint` 转述给用户;「服务暂时不可用」这类可稍后重试。
## Agent 规则
- **写操作先定环境**`+cache-clear` / `+cache-delete` 不指定 `--environment` 时会落到自动选中的环境——**没有多环境的应用会直接作用到线上 `online`(生产)**。不确定应用有没有多环境时,写操作显式传 `--environment`;纯查看(`+cache-get`)影响小,可以省略。
- **`+cache-clear` 会清掉整个环境的缓存**:执行前先跟用户确认环境无误、说明会清掉该环境全部缓存。已明确授权可直接带 `--yes`;遇到确认关卡(`confirmation_required`exit 10按 lark-shared 约定与用户确认后再补 `--yes` 重试,不要静默追加。
- **排查缓存内容优先用 `+cache-get`**:想看结构化、易读的内容用 `--format pretty`;想拿原始内容做精确比对用默认 JSON。
- **删 key 前先对齐 key**:用户只描述了业务含义、没给准确 key 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.3
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。"
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive认证/授权转 lark-shared。"
metadata:
requires:
bins: ["lark-cli"]
@@ -23,14 +23,15 @@ metadata:
不要使用本 skill
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`
- 把本地 Excel / CSV / `.base` 导入成 Base`lark-drive +import --type bitable`
- 把本地文件导入成 Base或将 Base 导出为本地文件,转 `lark-drive`
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参。
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先`lark-cli drive +import --type bitable`导入完成后再回到 Base 命令。
- 本地文件与 Base 之间的导入/导出`lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 先获取 Base Token 和所需 ID
@@ -38,6 +39,7 @@ metadata:
进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`
- 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "<url>" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。
- Base/Wiki URL 的 `table=` query 参数实际表示当前选中的顶层 block可能是数据表、仪表盘或 workflow不要按参数名自行当成 `table_id`。以 `+url-resolve` 返回的 `block_type` 以及 `table_id` / `dashboard_id` / `workflow_id` 为准;`selection_source=url_query` 只说明 URL 当前选中了该 block不代表它覆盖用户明确点名的目标。若用户点名的 dashboard 与 `block_name` 不一致,先用 `+dashboard-list` 按名称匹配;若只返回中性 `block_id`,按 hint 用 `+base-block-list` 确认类型。
- 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "<keyword>" --as user``--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。
- 文档嵌入 Base 标签:直接读取 `<bitable>` / `<base_refer>``token` 作为 `--base-token``table-id` 作为 `--table-id``view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`
- 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base用户要新建时才用 `+base-create`
@@ -48,6 +50,7 @@ metadata:
|---|---|---|
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema写入后报告新 Base 标识和 `permission_grant` |
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow资源内容继续用对应命令 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
@@ -57,13 +60,14 @@ metadata:
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue上传走本地文件下载/删除按 file token 或字段定位 |
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md);其余配置先 get 现状,再按返回结构更新 |
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md)删除前确认目标表单 |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list``+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`删除前确认目标表单 |
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
@@ -115,7 +119,11 @@ metadata:
## 表单与视图细节
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list``+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id``+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`
- 表单问题由数据表字段承载question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-questions-update` 是题目配置全量覆盖,不是 patch未传字段会回落默认值传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
@@ -146,13 +154,14 @@ metadata:
## 保留 Reference
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT`+data-query``filters` 结构是独立对象 DSL不使用公共 tuple filter 协议
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT不适用于 `+data-query`
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)workflow 入口与 steps JSON SSOT

View File

@@ -79,16 +79,23 @@ lark-cli base +data-query \
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--dsl <json>` | 是 | LiteQuery Protocol JSON DSL 查询语句 |
## 如何从链接中提取参数
## 如何从链接中解析参数
用户通常会提供如下 URL
```
https://example.feishu.cn/base/<base_token>?table=<table_id>
```text
https://example.feishu.cn/base/<base_token>?table=<block_id>
```
- `--base-token`:取 `/base/` 后面的字符串
- DSL 中的 `tableId`:取 `table=` 后面的值
不要直接把 URL 中的 `table=` 当成数据表 ID。它表示当前选中的 Base 顶层块,可能是数据表、仪表盘、工作流、文件夹或文档。先解析链接:
```bash
lark-cli base +url-resolve --url "<url>" --as user
```
- `--base-token`:使用返回的 `base_token`
- 仅当返回的 `block_type``table`DSL 中的 `tableId` 才使用返回的 `table_id`
- 如果返回的是其他块类型,按 `hint.next_step` 继续处理;如果只返回中性的 `block_id`,先用 `+base-block-list` 确认块类型,再选择实际要查询的数据表
## API 入参详情

View File

@@ -0,0 +1,179 @@
# Base Filter 条件结构(公共协议)
Filter 是一组「字段/操作符/值」条件的组合,用 `logic``and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构本文件是其公共协议SSOT
## 0. 适用范围
本协议只适用于以下场景:
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
## 1. 顶层结构
- 必须是 JSON 对象。
- 顶层结构是 `{logic?, conditions?}`
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`
- `conditions` 默认空数组。
- 每条条件写成 tuple`[field, operator, value?]`
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]``[field, "non_empty"]`
```json
{
"logic": "and",
"conditions": [
["状态", "intersects", ["Doing"]],
["负责人", "intersects", [{ "id": "ou_xxx" }]],
["截止时间", "empty"]
]
}
```
清空写法:
```json
{
"conditions": []
}
```
## 2. operator
可用 operator
- `==`
- `!=`
- `>`
- `>=`
- `<`
- `<=`
- `intersects`
- `disjoint`
- `empty`
- `non_empty`
## 3. value 写法
value 类型取决于条件引用对象(字段 / 题目)的类型。
### `text`
用字符串:
```json
["标题", "intersects", "发布"]
```
### `location`
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
```json
["位置", "intersects", "深圳"]
```
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
### `number` / `auto_number`
用数字:
```json
["工时", ">=", 3.5]
```
### `select`
用选项名数组:
```json
["状态", "intersects", ["Doing", "Blocked"]]
```
### `user` / `created_by` / `updated_by`
用对象数组:
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
```json
["负责人", "intersects", [{ "id": "ou_xxx" }]]
```
### `group_chat`
用对象数组:
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
```json
["负责群", "intersects", [{ "id": "oc_xxx" }]]
```
### `link`
用记录 id 对象数组:
```json
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
```
### `checkbox`
用布尔值:
```json
["完成", "==", true]
```
### `datetime` / `created_at` / `updated_at`
用相对时间关键字或 `ExactDate(...)`
```json
["截止时间", "==", "ExactDate(2026-01-01)"]
```
```json
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
```
```json
["截止时间", "==", "Today"]
```
可用关键字:
- `Today`
- `Yesterday`
- `Tomorrow`
### `formula` / `lookup`
- 筛选值类型由字段计算结果类型动态决定。
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
- 如果报错,再按错误提示把 `value` 改成对应类型。
字符串示例:
```json
["风险说明", "intersects", "高风险"]
```
数字示例:
```json
["汇总分", ">=", 80]
```
## 4. 易错点
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`
- `user` / `group_chat` / `link` 不要写成单个标量。
- `empty` / `non_empty` 不要硬塞无意义的 value。
- 日期条件稳定写法用 `ExactDate(...)``Today` / `Yesterday` / `Tomorrow`
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
## 5. 参考
- [lookup-field-guide.md](lookup-field-guide.md)

View File

@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[
{"type":"text","title":"您的姓名是?","required":true},
{"type":"text","title":"您的联系方式是?","required":false}
]'
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
# 添加单选题(带选项)
lark-cli base +form-questions-create \
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
# 添加带显隐条件visible_rule的问题当「是否需要发票」选择「是」时才显示「发票抬头」
lark-cli base +form-questions-create \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
```
## 参数
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效bool |
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
| `style` | 否 | 字段样式配置(见下方说明) |
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
### `style` 字段说明
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd``yyyy/MM/dd HH:mm``MM-dd``MM/dd/yyyy``dd/MM/yyyy` |
### `visible_rule` 显隐条件
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
- 引用的题目必须真实存在,否则会报错。
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null``conditions` 为空数组。
```json
{
"logic": "and",
"conditions": [
["是否需要发票", "==", "是"],
["报销金额", ">=", 1000]
]
}
```
详细的 `visible_rule` 结构顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
## 输出格式
返回创建成功的问题列表:
@@ -108,11 +137,15 @@ lark-cli base +form-questions-create \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1.`+form-questions-list` 查看现有问题
2. 确认要添加的问题内容
3. 执行命令并报告新建的问题 ID
1.确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`
2. `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

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