Compare commits

...

53 Commits

Author SHA1 Message Date
zhanghuanxu
44be8c986f fix(slides): close text-overlap false negatives and unify z-order checks
Fix missed overflow and occlusion cases in xml_text_overlap_lint: CJK
ambiguous-width and percent glyph width estimation, chart-vs-text
occlusion, full-canvas background-image exemption, and severity masking
in the width/height overflow dedupe. Consolidate the scattered raw
paint-order comparisons into is_drawn_behind / is_drawn_in_front_of so
stacking direction is decided in one place, with contract tests that
turn red if the fixes are reverted.
2026-07-31 18:30:39 +08:00
zhanghuanxu
a849ac3449 fix(slides): detect width-induced text wrap in xml_text_overlap_lint
The height-only check (text_may_overflow_shape) misses shapes that wrap
because their box is too narrow, not too short. Added a new width-axis
detector that:

- Flags single-line short labels/metrics whose estimated width exceeds
  the content box (0.85 risk band for latin runs, 1.18 tolerance for
  plain metrics, exact fit for pure CJK).
- Works independently of autoFit (shape-auto-fit only grows height).
- Preserves internal whitespace (e.g. "autofix      87%") so spaces
  are not collapsed away.
- Deduplicates with the height check so the same shape is not
  double-reported under the shared text_may_overflow_shape code.

The two axes share code="text_may_overflow_shape" and are distinguished
by overflow_axis="height"|"width". Regression test covers all three
real false-negative cases (bMP/bMp/bMm) plus negative controls.
2026-07-31 18:30:39 +08:00
zhanghuanxu
47a46658c8 fix(slides): remove order exemption in image-text occlusion detection
The order-based skip (`image.order <= text.order`) allowed images that
appear before text in XML to silently cover text glyphs. Remove it so
any geometric overlap is reported regardless of XML element order.

Also update the error hint to no longer suggest reordering XML as a
fix, since that no longer works.

Add a regression test verifying the new behavior.
2026-07-31 18:30:39 +08:00
zhanghuanxu
11f34b3e85 fix(slides): detect text-line overlap in xml_text_overlap_lint 2026-07-31 18:30:39 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +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
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
216 changed files with 25540 additions and 1262 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

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -2,6 +2,90 @@
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
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
@@ -1638,6 +1722,10 @@ 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
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

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

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

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

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -26,6 +26,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +56,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
}

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

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

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

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

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.76",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

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

View File

@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
if grep -Fq 'run.name !== "CI"' "$workflow"; then
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"

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

@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -20,7 +20,6 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}
eventData := buildEventData(runtime, startTs, endTs)

View File

@@ -81,6 +81,7 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
collapseDescription(out)
return out, nil
}

View File

@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
if _, ok := body["description"]; ok {
t.Fatalf("plain description must not be sent, got: %#v", body)
}
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
}
}
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"status": "confirmed",
"description": "[测试]\n友情提醒",
"description_rich": "友情提醒",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"status": "confirmed",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
},
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
func TestAgenda_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"description": "[表格]",
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body := map[string]interface{}{}
hasFields := false
for _, field := range []string{"summary", "description"} {
if runtime.Cmd.Flags().Changed(field) {
body[field] = runtime.Str(field)
hasFields = true
}
if runtime.Cmd.Flags().Changed("summary") {
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
if description, _ := event["description"].(string); description != "" {
result["description"] = description
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"fmt"
"image"
// Register the common image decoders so DecodeConfig can read intrinsic
// dimensions for PNG/JPEG/GIF sources.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
if err != nil {
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
}
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
if len(matches) == 0 {
return md, false, nil
}
var out strings.Builder
last := 0
changed := false
cache := map[string]string{}
for _, m := range matches {
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
src := strings.TrimSpace(md[srcStart:srcEnd])
if !isLocalImageSrc(src) {
continue
}
alt := md[altStart:altEnd]
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
if err != nil {
return "", false, err
}
out.WriteString(md[last:srcStart])
out.WriteString(uploadedURL)
last = srcEnd
changed = true
}
if !changed {
return md, false, nil
}
out.WriteString(md[last:])
return out.String(), true, nil
}
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
localPath := localImagePath(src)
if cached, ok := cache[localPath]; ok {
return cached, nil
}
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
info, err := runtime.FileIO().Stat(localPath)
if err != nil {
return "", common.WrapInputStatErrorTyped(err)
}
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: localPath,
FileName: filepath.Base(safePath),
FileSize: info.Size(),
ParentType: calendarMediaParentType,
ParentNode: &calendarID,
})
if err != nil {
return "", err
}
width, height := decodeImageDimensions(runtime, localPath)
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
cache[localPath] = uploadedURL
return uploadedURL, nil
}
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return 0, 0
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0
}
return cfg.Width, cfg.Height
}
func isLocalImageSrc(src string) bool {
if src == "" {
return false
}
lower := strings.ToLower(src)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
return false
case strings.HasPrefix(lower, "file://"):
return true
}
if i := strings.Index(src, "://"); i > 0 {
return false
}
return true
}
func localImagePath(src string) string {
s := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(s), "file://") {
if u, err := url.Parse(s); err == nil && u.Path != "" {
s = u.Path
}
}
if decoded, err := url.PathUnescape(s); err == nil {
return decoded
}
return s
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.feishu.cn"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
if width > 0 && height > 0 {
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
}
if size > 0 {
u += fmt.Sprintf("&im_size=%d", size)
}
return u
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"encoding/json"
"errors"
"image"
"image/png"
"net/url"
"os"
"path/filepath"
"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"
)
func TestIsLocalImageSrc(t *testing.T) {
cases := []struct {
src string
want bool
}{
{"./images/pic.png", true},
{"images/pic.png", true},
{"../assets/a.png", true},
{"/Users/me/Desktop/a.png", true},
{`C:\Users\me\a.png`, true},
{"file:///Users/me/a.png", true},
{"图片和附件/测试图片.png", true},
{"https://example.com/a.png", false},
{"http://example.com/a.png", false},
{"HTTPS://EXAMPLE.com/a.png", false},
{"data:image/png;base64,iVBOR", false},
{"ftp://host/a.png", false},
{"", false},
}
for _, c := range cases {
if got := isLocalImageSrc(c.src); got != c.want {
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestLocalImagePath(t *testing.T) {
cases := []struct{ in, want string }{
{"images/pic.png", "images/pic.png"},
{"images/my%20pic.png", "images/my pic.png"},
{"file:///Users/me/a.png", "/Users/me/a.png"},
}
for _, c := range cases {
if got := localImagePath(c.in); got != c.want {
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
// relies on: a Lark host (so token extraction triggers) whose final path
// segment is exactly the uploaded file token.
func TestBuildCalendarImagePreviewURL(t *testing.T) {
for _, tc := range []struct {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "feishu.cn"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("built URL not parseable: %v", err)
}
if !strings.Contains(u.Host, tc.hostFrag) {
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
t.Errorf("last path segment = %q, want token", last)
}
q := u.Query()
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
}
}
// With unknown dimensions the helper params are omitted entirely.
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
t.Errorf("expected no dimension params for unknown size, got %q", raw)
}
}
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
// pass through unchanged and never trigger an upload (runtime unused → nil).
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
md := "text ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)"
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if changed {
t.Errorf("changed = true, want false")
}
if got != md {
t.Errorf("markdown mutated: %q", got)
}
}
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
// mocks the drive upload, and asserts the create body's description_rich carries
// the uploaded token (not the local path).
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
}
reg.Register(uploadStub)
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
if uploadStub.CapturedBody == nil {
t.Fatalf("expected drive upload to be called")
}
if createStub.CapturedBody == nil {
t.Fatalf("expected create event to be called")
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "boxcnTOKEN123") {
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
}
if strings.Contains(dr, "./pic.png") {
t.Fatalf("local path should be rewritten away, got %q", dr)
}
}
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
// intrinsic width/height and byte size are appended to the rewritten drive URL
// (so the facade can populate originalWidth/originalHeight and the client can
// render the image inline).
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
}
if !strings.Contains(dr, "im_size=") {
t.Fatalf("description_rich should carry image byte size, got %q", dr)
}
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
t.Fatalf("expected error for absolute image path")
}
var ve *errs.ValidationError
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}
func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false

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))
@@ -66,10 +202,24 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return wrapDriveNetworkErr(err, "download failed: %s", err)
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
}
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

@@ -5,6 +5,8 @@ package drive
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
@@ -21,6 +23,30 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
// +download intact while giving callers a preview-based path to view content.
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
return err
}
if strings.Contains(problem.Hint, "drive +preview") {
return err
}
hint := driveDownloadForbiddenPreviewHint()
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = hint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
return err
}
func driveDownloadForbiddenPreviewHint() string {
const tokenArg = "<FILE_TOKEN>"
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."

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,691 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
}
}
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_403/download",
Status: http.StatusForbidden,
RawBody: []byte("permission denied"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_403",
"--output", "blocked.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 403 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryNetwork {
t.Fatalf("category=%q, want network", problem.Category)
}
if problem.Code != http.StatusForbidden {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
}
if !strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
}
if strings.Contains(problem.Hint, "file_403") {
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
}
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
}
}
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_missing/download",
Status: http.StatusNotFound,
RawBody: []byte("not found"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_missing",
"--output", "missing.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 404 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
}
if strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
}
}
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)
}

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